更新
@@ -1,3 +1,7 @@
|
||||
# 此文件是源码开发/企业部署模板,不会被复制进发布 ZIP/.app。
|
||||
# 生产配置应由受控启动器、设备管理或进程环境注入;禁止把密码、token、
|
||||
# UserSig、TRTC SecretKey 或其他长期凭据写进本文件或发布包。
|
||||
|
||||
# 后端根地址。程序会自动追加 /adminapi;也可直接填写以 /adminapi 结尾的地址。
|
||||
DOCTOR_API_BASE_URL=https://api.example.com
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.pytest-tmp-*/
|
||||
artifacts/pytest_*/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
@@ -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) 的准入清单完成摄像头、麦克风、设备插拔、休眠恢复和弱网实测。
|
||||
|
||||
## 工程结构
|
||||
|
||||
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 74 KiB |
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 px(small 24 px、large 40 px);但编辑抽屉底部按钮被局部覆盖为最小 40 px,移动端最小 44 px(`edit.vue:1732-1740,1905-1913`)。
|
||||
|
||||
### 2.3 间距基线和全局行为
|
||||
|
||||
Tailwind 间距映射是 4 px 基线:`1=4`、`2=8`、`2.5=10`、`3=12`、`3.5=14`、`4=16`、`5=20`、`6=24`(`tailwind.config.js:103-125`)。业务文件里的 8/10/12/14/16/18/20/24 px 与该节奏一致。
|
||||
|
||||
直接影响这些页面的全局规则:
|
||||
|
||||
- Dialog 居中,最大宽 `calc(100vw - 30px)`,圆角 5 px(`src/styles/element.scss:11-27`)。
|
||||
- 全局 Drawer 主内边距变量为 16 px,Header 内边距 `13px 16px`、下边框 1 px lighter;预约 Drawer 没有重写这些值,编辑 Drawer 则只额外重写背景与边框(`src/styles/element.scss:46-56`)。
|
||||
- 全局表格字号 14 px、表头主文字色、表头字重 400、表头底 `#f8f8f8`(`src/styles/element.scss:58-68`; `src/styles/var.css:10`)。因此编辑根节点的 12 px 不应错误地传成所有表格单元字号。
|
||||
- Input、Select、Textarea 聚焦时出现 2 px primary-light 外环,radio/checkbox active 也有 2 px 外环;校验错误时切换 danger-light 外环(`src/styles/element.scss:140-175`)。
|
||||
- Tabs 底线全局压至 1 px(`src/styles/element.scss:131-133`)。
|
||||
- 消息与通知层级为 9999(`src/styles/element.scss:1-9`)。
|
||||
- 小于等于 768 px 时分页隐藏页码跳转与 page-size 选择器(`src/styles/element.scss:177-183`)。
|
||||
- Tailwind 会导入 base/components/utilities(`src/styles/tailwind.css:1-3`),但 Element 按钮背景、focus、hover 被重新还原为 Element 变量,避免被 Tailwind reset 覆盖(`src/styles/element.scss:186-200`)。
|
||||
- `body` 为 14 px 主文字、`overflow:hidden`、最小宽 375 px;页面/Drawer 自己承担滚动(`src/styles/public.scss:1-3`)。全局 `.form-tips` 为 12 px secondary、行高 24 px、上间距 4 px(`src/styles/public.scss:4-6`)。
|
||||
|
||||
暗色主题另有一套直接全局变量:页面/表面为 `#0a0a0a / #1d2124 / #1d1e1f`,主/常规/次要文字为 `#e5eaf3 / #cfd3dc / #a3a6ad`,边框从 `#636466` 到 `#2b2b2c`,遮罩为 `rgba(0,0,0,.8)`(`src/styles/dark.css:1-31`)。不过三个诊单主视图及卡片大量显式写入白底、浅蓝渐变和 slate 色,不能完整随暗色变量切换;本规格后续均按源码默认浅色态记录,暗色不能视为已完整适配。
|
||||
|
||||
---
|
||||
|
||||
## 3. 独立只读详情页 `readonly.vue`
|
||||
|
||||
### 3.1 页面骨架
|
||||
|
||||
源码结构是单列滚动页,不使用 Tabs(`readonly.vue:1-149`):
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ ← 返回 诊单只读详情 张三 男 · 42岁 [未服务 3天] │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ [患者信息摘要 PatientInfoCard] │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ [患者病例 PatientCaseCard] │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ [日常记录 DailyMatrix] 按权限可见 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ [医生备注/舌象/报告 NoteTimeline] │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ [业务订单 PatientOrderList] 按权限可见 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ [视频录制回放 CallRecordPanel] 按权限可见 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ [聊天 ImChatRecordPanel] 按权限可见 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ [指派医助记录 AssignLogPanel] 按权限可见 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ [挂号记录 AppointmentRecordPanel] 按权限可见 │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
渲染顺序严格来自 `readonly.vue:39-146`;异常时在 hero 下方显示错误空态(`readonly.vue:31-36`)。页面根容器垂直间距 16 px、内边距 16 px(`readonly.vue:269-273`)。
|
||||
|
||||
### 3.2 顶部 Hero
|
||||
|
||||
Hero 是可换行的左右布局:`display:flex; justify-content:space-between; flex-wrap:wrap; gap:12px`,内边距 `12px 16px`,圆角 12 px,边框 `#dde7ff`,背景为 `#f5f8ff → #eef3ff` 的浅蓝渐变(`readonly.vue:275-285`)。
|
||||
|
||||
- 页面标题:16 px / 700(`readonly.vue:286-303`)。
|
||||
- 患者姓名:18 px / 700;性别年龄 13 px(`readonly.vue:305-320`)。
|
||||
- 未服务状态胶囊:12 px / 600,内边距 `4px 10px`,999 px 全圆角(`readonly.vue:323-330`)。
|
||||
- 状态色:正常绿 `#16a34a` / 边框 `#bbf7d0`;提醒橙 `#ea580c` / `#fed7aa`;严重红 `#dc2626` / `#fecaca`(`readonly.vue:332-355`)。从未记录时使用 Element placeholder 灰。
|
||||
|
||||
### 3.3 卡片规格
|
||||
|
||||
通用详情卡为白底、1 px `#e6ebf2` 边框、14 px 圆角、18 px 内边距、14 px 内部纵向间距和轻阴影(`readonly.vue:364-373`)。卡片标题 15 px / 700,标题前为 `3 × 16 px` 蓝色标记(`readonly.vue:375-396`)。DailyMatrix 外层额外 16 px 内边距以对齐矩阵(`readonly.vue:398-405`)。错误空态卡保持 14 px 圆角并有 40 px 纵向内边距(`readonly.vue:357-362`)。
|
||||
|
||||
患者摘要内部是浅蓝 Hero 卡:圆角 12 px,内边距 `16px 18px`,行距 6 px;患者名 22 px / 700,其余信息 13 px、行高 1.7(`components/PatientInfoCard.vue:78-123`)。字段顺序是:
|
||||
|
||||
1. 姓名。
|
||||
2. 脱敏手机 · 性别 · 年龄。
|
||||
3. 身高 / 体重 · 地区。
|
||||
4. 预约日期时间 · 时段。
|
||||
5. 医生 / 医助。
|
||||
6. 预约状态 · 是否已开方。
|
||||
7. 备注(有值才显示)。
|
||||
|
||||
顺序由 `components/PatientInfoCard.vue:6-25` 确定。
|
||||
|
||||
### 3.4 病例字段顺序与网格
|
||||
|
||||
病例卡以分组标题 + 网格键值展示;组间 `gap:10px`,除首组外顶部 12 px 内边距并有 dashed 分隔线(`components/PatientCaseCard.vue:258-269`)。分组标题 13 px / 600、蓝色,前有 4 px 圆点(`components/PatientCaseCard.vue:271-287`)。网格横向间距 16 px、纵向 8 px,并提供 1/2/3/4 等分列(`components/PatientCaseCard.vue:289-305`)。键值字号 12.5 px、行高 1.55,label 最小宽 58 px;label 为 `#6b7280`,value 为 `#1f2937` / 500,空值为 `#c0c4cc`(`components/PatientCaseCard.vue:307-341`)。
|
||||
|
||||
只读病例的真实字段顺序(`components/PatientCaseCard.vue:8-124`):
|
||||
|
||||
| 分组 | 列数 | 顺序 |
|
||||
|---|---:|---|
|
||||
| 基本信息 | 4 | 诊单ID、姓名、身份证、手机、性别、年龄、婚姻、地区 |
|
||||
| 生命体征 | 4 | 身高、体重、高压、低压、诊断类型、空腹血糖、在用药物(多行) |
|
||||
| 主诉 | 4 | 当地诊断日期、糖尿病病史、当地医院、当地医院诊断结果 |
|
||||
| 现病史 | 3 | 口腔、饮水、体重变化、脂肪肝、饮食(跨2列)、肢体、睡眠、眼、头、出汗、皮肤、小便、大便、腰肾、其他补充(跨3列、多行) |
|
||||
| 既往史 | 1 | 既往史(整行) |
|
||||
| 其他病史 | 混合 | 外伤、手术、过敏、家族、妊娠 |
|
||||
| 补充与意见 | 1 | 病史补充(有值才显示)、处方意见 |
|
||||
|
||||
超标指标采用 `#dc2626`、700 字重并带上箭头(`components/PatientCaseCard.vue:343-358`)。处方意见为 13 px、`12px 14px` 内边距、8 px 圆角、左侧 3 px 蓝条(`components/PatientCaseCard.vue:360-368`)。
|
||||
|
||||
### 3.5 响应式事实
|
||||
|
||||
`readonly.vue` 自身没有 media query;Hero 仅通过 `flex-wrap` 避免左右信息互相挤压。`PatientCaseCard.vue` 也没有窄屏降列规则,所以 4 列病例网格不会自动变成 1 列。实现 PySide 时不能把“可换行 Hero”误判为整页已经完整响应式。
|
||||
|
||||
---
|
||||
|
||||
## 4. 编辑 / 抽屉只读 `edit.vue`
|
||||
|
||||
### 4.1 框架与模式
|
||||
|
||||
右侧 Drawer 宽度固定为视口的 60%,方向 RTL;只读时 z-index 4000,编辑/新增时 1500(`edit.vue:2-10`)。抽屉有三种标识:只读 `info`、新建 `success`、编辑 `warning`,标题下显示“患者名 · 诊单ID”(`edit.vue:11-54,859-863`)。
|
||||
|
||||
```text
|
||||
桌面(右侧 60% Drawer)
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ 编辑诊单 [编辑] 患者名 · #123 │ Header
|
||||
├───────────────────────────────────────────────────────────────┤
|
||||
│ 病历 │ 医生备注 │ 日常记录 │ 处方 │ 业务订单 │ 视频 │ ... → │ Tabs
|
||||
├───────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [锁定提示 / 隐私提示,按条件] │
|
||||
│ 诊单ID [_____________________________] │
|
||||
│ 姓名 [____________] 身份证 [____________] │
|
||||
│ │
|
||||
│ ──────────────── 生命体征 ──────────────── │
|
||||
│ 婚姻 [____] 身高 [____] 体重 [____] │ Scroll body
|
||||
│ ... │
|
||||
│ ──────────────── 现病史 ───────────────── │
|
||||
│ [可换行 radio / checkbox 组] │
|
||||
│ │
|
||||
├───────────────────────────────────────────────────────────────┤
|
||||
│ [取消/关闭] [保存] │ Footer slot
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
`viewOnly` 模式没有切换到独立详情卡,而是给病历 `fieldset` 禁用输入,并隐藏保存;锁定患者信息时显示 warning alert(`edit.vue:57-84,766-781`)。
|
||||
|
||||
### 4.2 Header、Tabs、Footer
|
||||
|
||||
- Header 背景 `#fff → #f8fafc`,底边 1 px `#e2e8f0`(`edit.vue:1776-1785`)。标题 18 px / 600 / `#0f172a`,行高 1.3;副标题 13 px(`edit.vue:1670-1717`)。
|
||||
- Body 背景 `#f8fafc`(`edit.vue:1787-1790`)。
|
||||
- Tabs 横向滚动,滚动条 4 px;nav 使用 `max-content` 且最小宽 100%,tab 水平内边距 14 px,常态 `#64748b` / 500,激活态为 Element primary / 600,激活条 3 px(`edit.vue:1798-1858`)。
|
||||
- Footer 最终生效样式为白底、上边框 1 px `#e2e8f0`,内容右对齐、gap 12 px、内边距 `14px 20px 18px`(`edit.vue:1719-1730,1860-1864`)。按钮最小高 40 px、水平内边距 20 px、600 字重、圆角 10 px(`edit.vue:1732-1740`)。
|
||||
|
||||
这里没有 CSS `position: sticky`。底部操作之所以视觉上固定,是因为它使用 Element Drawer 的独立 footer slot,位于可滚动 body 之外(模板 `edit.vue:766-781`;结构样式 `edit.vue:1792-1796`)。PySide 应以固定底栏实现,而不是在滚动内容末尾放按钮。
|
||||
|
||||
### 4.3 Tab 顺序、权限与内容
|
||||
|
||||
Tab 的准确顺序和门槛(`edit.vue:55-759`):
|
||||
|
||||
| 序号 | Tab | 条件 | 内容组件 |
|
||||
|---:|---|---|---|
|
||||
| 1 | 病历 | 始终 | 内联表单 |
|
||||
| 2 | 医生备注 | 始终 | `NoteTimeline` |
|
||||
| — | 跟踪备注 | **已注释,不渲染** | 原拟 `TrackingNoteTimeline`(`edit.vue:644-654`) |
|
||||
| 3 | 日常记录 | `tcm.diagnosis/dailyRecord` | `DailyMatrix` |
|
||||
| 4 | 处方 | `tcm.diagnosis/chufang` | `CaseRecordList` |
|
||||
| 5 | 业务订单 | `tcm.diagnosis/patientOrders` | `PatientOrderList` |
|
||||
| 6 | 视频录制回放 | `tcm.diagnosis/huifang` | `CallRecordPanel` |
|
||||
| 7 | 聊天 | `tcm.diagnosis/chat` | `ImChatRecordPanel` |
|
||||
| 8 | 指派医助记录 | `tcm.diagnosis/assign` 或 `tcm.diagnosis/detail` | `AssignLogPanel` |
|
||||
| 9 | 挂号记录 | `doctor.appointment/lists` | `AppointmentRecordPanel` |
|
||||
|
||||
权限变化后若当前 Tab 失权,代码会主动回到 `basic`(`edit.vue:883-913`)。除病历外的大部分 Tab 为 lazy 内容,首次选中才加载。
|
||||
|
||||
### 4.4 病历表单布局、标签和控件
|
||||
|
||||
桌面表单 label width 为 160 px(`edit.vue:61-63`)。根字号、label、输入/textarea/radio/checkbox/button 均显式为 12 px;label 为 500 / `#606266`(`edit.vue:1476-1504,1532-1556`)。表单项底部间距 18 px(`edit.vue:1528-1530`)。输入、选择、日期、数字框圆角 10 px(`edit.vue:1645-1667`);普通输入高度沿用 Element 32 px。
|
||||
|
||||
分组标题的上/下外边距为 28/22 px;中间文字 14 px / 600 / `#0f172a`、`7px 16px` 内边距、999 px 胶囊圆角,两侧 1 px `#e2e8f0` 横线(`edit.vue:1506-1525`)。单选紧凑组换行、gap 12 px;复选组 gap `12px 16px`;普通 radio/checkbox 右、下间距分别 16/8 px(`edit.vue:1558-1604`)。帮助文本 12 px、`#909399`、行高 1.5、顶部 4 px(`edit.vue:1606-1611`)。
|
||||
|
||||
### 4.5 编辑表单的真实字段顺序
|
||||
|
||||
以下只列实际渲染字段,不把 `formData` 中未渲染的属性当作 UI(模板 `edit.vue:57-632`):
|
||||
|
||||
| 区段 | 栅格 | 字段顺序与控件 |
|
||||
|---|---|---|
|
||||
| 顶部 | 24 | 诊单ID(只读) |
|
||||
| 基本身份 | 12 + 12 | 姓名;身份证 |
|
||||
| 联系信息 | 12 + 12 | 手机;性别 |
|
||||
| 年龄 | 12 | 年龄 |
|
||||
| **生命体征** | 8 + 8 + 8 | 婚姻;身高;体重 |
|
||||
| | 8 + 8 + 8 | 地区;高压;低压 |
|
||||
| | 8 | 空腹血糖 |
|
||||
| | 12 | 诊断类型 |
|
||||
| | 12 + 12 | 状态;渠道 |
|
||||
| | 12 | 统计端就诊卡 |
|
||||
| | 12 | 在用药物,textarea,3 行,maxlength 2000 |
|
||||
| **主诉** | 12 + 12 | 当地诊断日期;糖尿病病史 |
|
||||
| | 24 | 当地医院诊断结果(check buttons) |
|
||||
| | 24 | 当地医院名称 |
|
||||
| **现病史** | 24 | 口腔感觉(check buttons) |
|
||||
| | 24 | 每日饮水量(radio buttons) |
|
||||
| | 24 | 近月体重变化(radio buttons) |
|
||||
| | 24 | 脂肪肝(radio buttons) |
|
||||
| | 24 | 饮食(check buttons) |
|
||||
| | 24 × 9 | 肢体、睡眠、眼睛、头部、出汗、皮肤、小便、大便、腰肾 |
|
||||
| | 24 | 其他补充,textarea,3 行 |
|
||||
| **既往史** | 24 | 既往史(check buttons) |
|
||||
| **其他病史** | 8 + 8 + 8 | 外伤;手术;过敏 |
|
||||
| | 12 + 12 | 家族;妊娠 |
|
||||
| **诊断信息** | 24 | 病史补充,textarea,2 行 |
|
||||
|
||||
具体模板行:顶部/身份 `edit.vue:86-194`;生命体征 `edit.vue:199-368`;主诉 `edit.vue:369-412`;现病史 `edit.vue:414-549`;既往史 `edit.vue:552-564`;其他病史 `edit.vue:567-613`;诊断信息 `edit.vue:616-629`。
|
||||
|
||||
当前模板没有显示 `syndrome_type`、`diabetes_type`、舌象、脉象、治则、医嘱等字段,即便脚本模型中存在,也不应擅自加入视觉复刻。
|
||||
|
||||
### 4.6 移动端
|
||||
|
||||
小于等于 768 px 时(`edit.vue:1876-2004`):
|
||||
|
||||
- Drawer 变为 100vw;header `12px 14px`,body `10px 12px`。
|
||||
- Footer 改为纵向、按钮全宽,gap 10 px,内边距 `12px 14px + safe-area`,按钮最小 44 px。
|
||||
- 标题 17 px;Tab 高/行高 44 px、字号 13 px、水平内边距 12 px。
|
||||
- label 改到控件上方:100% 宽、左对齐、下间距 6 px;表单项底部 14 px。
|
||||
- 所有 `el-col` 强制 100%,数字框 100%;radio gap `8px 16px`,checkbox `8px 10px`。
|
||||
- 分组标题外边距 18/14 px,文字 13 px、水平内边距 10 px。
|
||||
|
||||
```text
|
||||
移动端(<=768)
|
||||
┌──────────────────────┐
|
||||
│ 标题 [状态] × │
|
||||
│ 患者 · 诊单ID │
|
||||
├──────────────────────┤
|
||||
│ ← 可横向滚动 Tabs → │
|
||||
├──────────────────────┤
|
||||
│ 姓名 │
|
||||
│ [__________________] │
|
||||
│ 身份证 │
|
||||
│ [__________________] │
|
||||
│ ... 全部单列 ... │
|
||||
├──────────────────────┤
|
||||
│ [取消/关闭 全宽] │
|
||||
│ [保存 全宽,44px+] │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 预约抽屉 `appointment.vue`
|
||||
|
||||
### 5.1 骨架与字段顺序
|
||||
|
||||
预约同样是 RTL 右侧 Drawer,宽 60%,z-index 2000,且点击遮罩不能关闭(`appointment.vue:2-9`)。表单 label width 为 100 px(`appointment.vue:19`)。
|
||||
|
||||
```text
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ 预约面诊 × │
|
||||
├───────────────────────────────────────────────────────────────┤
|
||||
│ [今日重复预约警告 / 其他日期提示,条件显示] │
|
||||
│ 上次面诊 2026-08-01 10:30 │
|
||||
│ 预约方式 (●) 按时间预约 │
|
||||
│ 预约类型 (●) 视频面诊 │
|
||||
│ 预约患者 (●) 当前患者 │
|
||||
│ 渠道来源 [请选择 ▼] │
|
||||
│ 自媒体详情 [请选择 ▼] ← 仅自媒体渠道 │
|
||||
│ 预约医生 ( ) 医生A ( ) 医生B ... │
|
||||
│ 预约时间 [08月10日 周一] [08月11日 周二] ... │
|
||||
│ ┌───────────────────────────────────────────┐ │
|
||||
│ │ 可预约时段 [刷新] │ │
|
||||
│ │ [09:00 可约] [09:30 已满] [10:00 可约] ...│ │
|
||||
│ └───────────────────────────────────────────┘ │
|
||||
│ 备注 [_________________________________________] │
|
||||
├───────────────────────────────────────────────────────────────┤
|
||||
│ [取消] [确认预约] │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
字段顺序严格为(`appointment.vue:21-181`):
|
||||
|
||||
1. 上次面诊(文本)。
|
||||
2. 预约方式(当前只呈现按时间预约)。
|
||||
3. 预约类型(视频)。
|
||||
4. 预约患者(当前患者)。
|
||||
5. 渠道来源(必填 select)。
|
||||
6. 自媒体详情(渠道命中自媒体白名单时必填)。
|
||||
7. 预约医生(radio 列表)。
|
||||
8. 预约时间:先日期按钮,后可预约时段矩阵。
|
||||
9. 备注(textarea,2 行)。
|
||||
10. Footer:取消、确认预约。
|
||||
|
||||
### 5.2 尺寸、颜色与交互状态
|
||||
|
||||
- 渠道控件宽 100%、最大宽 360 px(`appointment.vue:741-750`)。
|
||||
- 医生/单选组可换行,gap 12 px(`appointment.vue:752-765,911-921`)。
|
||||
- 日期按钮 flex-wrap、gap 10 px、最小宽 130 px、高 40 px、字号 14 px、圆角 8 px;hover 上移 2 px并加阴影(`appointment.vue:771-789`)。
|
||||
- 时段容器底 `#f8f9fa`、圆角 8 px、内边距 16 px;标题 15 px / 600 / `#303133`(`appointment.vue:791-808`)。
|
||||
- 时段网格 `auto-fill minmax(110px,1fr)`、gap 10 px、最大高 450 px并滚动,滚动条宽 6 px(`appointment.vue:810-830`)。
|
||||
- 时段卡白底,2 px `#e4e7ed` 边框、8 px 圆角、最小高 70 px、水平内边距 8 px;时间 15 px / 600 / `#303133`,状态 12 px / `#909399`(`appointment.vue:831-862`)。
|
||||
- 可约 hover:`#409eff` 边框、`#ecf5ff` 底、上移并加阴影(`appointment.vue:864-873`)。
|
||||
- 不可约:`#f5f7fa` 底、opacity 0.6、文字 `#c0c4cc`(`appointment.vue:875-894`)。
|
||||
- 选中:`#409eff → #66b1ff` 渐变、白字和阴影(`appointment.vue:896-909`)。
|
||||
- Footer 右对齐、gap 12 px、上下内边距 12 px(`appointment.vue:923-928`)。
|
||||
|
||||
状态逻辑是视觉的一部分:未选医生时显示“请先选择医生”,无排班显示空态;日期只来自当前及未来有效排班(`appointment.vue:299-326`);当天过去时段禁用(`appointment.vue:328-357`);当天已有预约显示 warning,其他日期重复信息为 info(`appointment.vue:359-375`)。提交按钮只有医生、日期、时间、渠道以及条件性渠道详情齐全,且不存在当天冲突时才可用(`appointment.vue:377-386`)。
|
||||
|
||||
### 5.3 响应式事实
|
||||
|
||||
`appointment.vue` 没有移动端 media query。内部医生、日期和 slot 网格会换行,但 Drawer 在窄屏仍保持 60% 视口宽。这是后台源码的真实缺口;PySide 若要求窗口缩小时可用,应保留桌面 60% 的视觉比例,同时设置合理最小内容宽并在不足时切到全宽/单列,而不声称这是 Vue 原实现已有行为。
|
||||
|
||||
---
|
||||
|
||||
## 6. 备注、日常记录、处方/订单与历史关联区
|
||||
|
||||
### 6.1 医生备注 / 舌象 / 报告
|
||||
|
||||
编辑 Drawer 和独立只读页均使用 `NoteTimeline`(`edit.vue:633-643`; `readonly.vue:56-72`)。编辑态顶部按权限显示“新增备注、选择舌象、选择报告”;只读态隐藏这些操作(`src/views/patient/reception/components/NoteTimeline.vue:1-123`)。
|
||||
|
||||
视觉规格(`NoteTimeline.vue:272-436`):
|
||||
|
||||
- 顶部动作行 gap 12 px、底部分隔线。
|
||||
- 时间轴左内边距 22 px,轴线 2 px `#ebeef5`;节点 10 px、`#409eff`,外圈 `#ecf5ff`。
|
||||
- 日期 13 px / 600 / `#303133`,等宽数字;正文 12.5 px / `#606266` / 1.6 行高。
|
||||
- 舌象缩略图 `64 × 64 px`、圆角 6 px、带边框;删除为红色动作。
|
||||
- 报告文件 chip 为 `6px 10px`、圆角 6 px、12 px primary 字,最大宽 180 px。
|
||||
- 上传格 `90 × 90 px`、1 px dashed `#dcdfe6`、圆角 6 px、12 px;hover 转 primary。
|
||||
- 新增备注 Dialog 宽 480 px,textarea 4 行、maxlength 500。
|
||||
|
||||
`TrackingNoteTimeline` 当前并未进入可见 Tab:`edit.vue:644-654` 将整段注释。不能在复刻中把它当作现存“跟踪备注”页签。
|
||||
|
||||
### 6.2 日常记录、趋势与待办
|
||||
|
||||
DailyMatrix 顶部提供最近 7 天 / 30 天 / 自定义日期和日期范围;右侧有本人记录图例、编辑动作与血糖血压/饮食/运动/备注/刷新按钮,readonly 仅保留刷新(`components/DailyMatrix.vue:1-129`)。矩阵的指标顺序为:空腹、餐后2小时、其他血糖、血压、西药、胰岛素、早餐、午餐、晚餐、运动、跟踪备注(`components/DailyMatrix.vue:410-485`)。固定指标列 120 px,日期列最小 100 px。
|
||||
|
||||
- 根内边距 16 px;toolbar 换行、gap 12 px、下间距 12 px(`DailyMatrix.vue:1090-1105`)。
|
||||
- 趋势卡上间距 16 px、内边距 `16px 18px`、圆角 10 px,图表高 280 px(`DailyMatrix.vue:1124-1158`)。
|
||||
- 超标红 `#dc2626` / 700;本人记录用紫色浅底,徽章 `#6d28d9 / #ede9fe / #ddd6fe`(`DailyMatrix.vue:1182-1225`)。
|
||||
- <=768 px 时根内边距 12 px,图表内边距 12 px、高 240 px(`DailyMatrix.vue:1240-1260`)。
|
||||
|
||||
待办区域位于 DailyMatrix 下方,标题 14 px / 600。表格字段依次为提醒时间、内容、状态、创建人、推送时间、错误信息、操作;toolbar 可筛全部/待推送/已推送/失败/已取消(`components/DiagnosisTodoList.vue:1-112`)。新增 Dialog 宽 520 px、label 100 px,字段顺序为提醒时间、内容(4 行/500 字)、提醒人;toolbar 换行、gap 8 px、下间距 12 px,分页右对齐(`DiagnosisTodoList.vue:291-319`)。
|
||||
|
||||
### 6.3 处方 / 病例历史
|
||||
|
||||
`CaseRecordList` 顶部编辑态显示小号 primary“开方”,只读时隐藏。表格字段顺序:就诊日期(120)、就诊编号(120)、诊断(min 160)、处方摘要(min 180)、医生(90)、状态(140)、操作(120 固定右侧);根内边距 20 px(`components/CaseRecordList.vue:1-39,103-106`)。
|
||||
|
||||
点击“开方”进入直接依赖的全局 `@/components/tcm-prescription/index.vue`,不是在诊单 Tab 内原地编辑:它另开 1200 px Drawer、禁止点遮罩关闭,表单 label 100 px(`src/components/tcm-prescription/index.vue:1-10`)。编辑态顺序为:
|
||||
|
||||
1. 患者信息:姓名 / 性别 / 年龄三等分,电话 / 门诊号两等分。
|
||||
2. 诊断信息:面象 / 舌象两等分,临床诊断整行 2 行 textarea;舌象详情和脉象详情在模板中被注释(`index.vue:11-76`)。
|
||||
3. 中药处方 RP:药材总数与锁定提示;添加主方、添加辅方、处方库导入、粘贴导入;处方价格;主方卡片网格;辅方卡片网格(`index.vue:78-216`)。
|
||||
4. 用法信息:处方类型、主方用法、条件性辅方用法、剂数、剂量单位、用法、服用时间、服用方式、忌口、其他说明、医师、是否共享、医师签名(`index.vue:217-531`)。
|
||||
|
||||
信息分区间距 20 px、内边距 16 px、底 `#f5f7fa`、圆角 6 px;标题 15 px / 600 / `#303133`(`index.vue:2500-2516`)。RP toolbar 可换行,gap `12px 20px`、内边距 `14px 16px`、圆角 10 px、lighter 边框;标题 16 px、说明/锁定文字 12 px。药材编辑区桌面固定 4 列、gap 12 px,卡片白底、10 px 内边距、6 px 圆角;仅在 <=960 px 时让操作按钮行占满,**没有**把 4 列药材网格降列(`index.vue:2522-2690`)。签名区最大宽 600 px、1 px `#dcdfe6`、圆角 4 px(`index.vue:2694-2711`)。
|
||||
|
||||
保存后同一 Drawer 切换为处方预览:Tab 为“药房联 / 处方联”,上方工具条与作废/审核驳回状态,主体是 A4 `210 × 297 mm` 白纸,`8mm 10mm` 内边距、13 px 字号、28 px / 700 标题(`index.vue:536-754,2714-2782`)。患者信息是 4 列表格,药材是 2 列,底部医师/类型/天数/剂量为网格(`index.vue:2807-2966,2990-3035`)。未保存编辑态才提供 Drawer footer(`index.vue:899-905`)。
|
||||
|
||||
### 6.4 业务订单
|
||||
|
||||
订单 Tab/卡片顶部先显示“全局就诊序号偏移”工具条:label、tooltip、0–20 数字框(宽 120 px)、保存按钮(`components/PatientOrderList.vue:7-42`)。工具条换行、`12px 14px` 内边距、1 px lighter 边框、8 px 圆角、light fill(`PatientOrderList.vue:252-276`)。
|
||||
|
||||
订单表为 bordered + striped,字段顺序和列宽来自 `PatientOrderList.vue:44-106`:
|
||||
|
||||
1. 订单号,min 200。
|
||||
2. 全局就诊序号,96。
|
||||
3. 数量统计,96,Tag。
|
||||
4. 金额,120,右对齐红字。
|
||||
5. 医生,110。
|
||||
6. 医助,110。
|
||||
7. 履约状态,110,Tag。
|
||||
8. 创建时间,min 170。
|
||||
9. 详情,100,固定右侧。
|
||||
|
||||
默认 page size 为 10,底部带分页,点击详情打开只读订单详情(`PatientOrderList.vue:108-148`)。
|
||||
|
||||
订单详情复用 `PrescriptionOrderDetailDrawer` 的 readonly 受限版,而不是订单页内展开(`PatientOrderList.vue:113-124`)。该共享 Drawer 宽 80%,Header `16px 24px`,标题 18 px / 600,订单号与药房单号用圆角 Tag;加载态为 12 行 skeleton,body 水平内边距 24 px并独立纵向滚动(`src/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue:13-50,1752-1757`)。内容顺序为:
|
||||
|
||||
1. 金额概览:总金额、已付总额、退款金额、需代收、已付笔数;响应式 2 / 3 / 5 列(`PrescriptionOrderDetailDrawer.vue:154-197`)。
|
||||
2. 两栏主区:左侧处方详情,右侧未关联收款与关联收款;收款表顺序为 ID、单号、类型、金额、状态、方式(readonly 隐藏)、创建人、创建时间(`PrescriptionOrderDetailDrawer.vue:200-545`)。
|
||||
3. 履约与收货信息:3 列 descriptions,label 宽 120 px(`PrescriptionOrderDetailDrawer.vue:551-680,1782-1785`)。
|
||||
4. 物流轨迹与操作日志(有数据/开启附加加载时)(`PrescriptionOrderDetailDrawer.vue:683-824`)。
|
||||
|
||||
readonly 明确隐藏顶部告警、流程步骤、挂号关联、内部成本、药材明细、收款方式和外部物流更新等完整版内容,但金额概览、主辅方用量/服法和基础详情仍共享(`PrescriptionOrderDetailDrawer.vue:2-11`)。面板统一 8 px 圆角,Header `14px 16px`、Body 16 px(`PrescriptionOrderDetailDrawer.vue:1759-1780`)。
|
||||
|
||||
### 6.5 视频、聊天与历史表
|
||||
|
||||
**视频录制回放。** CallRecordPanel 编辑态有上传 toolbar;字段为播放(min 320)、开始时间 170、结束时间 170、通话类型 100、房间 180、时长 110、状态 90、录制 100、上传 180(仅编辑)(`components/CallRecordPanel.vue:1-74`)。空态内边距 `28px 12px`,标题 14 px、说明 12 px / 1.6(`CallRecordPanel.vue:188-214`)。内嵌视频最大高 180 px、圆角 4 px、黑底;播放覆盖层为 `rgba(0,0,0,.62)`、白色 12 px(`components/RecordingVideoPlayer.vue:322-359`)。
|
||||
|
||||
**聊天。** 顶部 info alert + 同步/刷新 toolbar,消息区最大高 `min(60vh,520px)`、gap 16 px。单条最大宽 88%;患者消息靠左、灰底,医生消息靠右、primary 浅底;meta 12 px,气泡 14 px、1.5 行高、`10px 12px` 内边距、8 px 圆角;图片最大 `240 × 200 px`(`components/ImChatRecordPanel.vue:1-70,200-293`)。
|
||||
|
||||
**医助指派历史。** 字段依次为时间 175、原医助 min 120、新医助 min 120、继承 72 Tag、快照订单创建人 min 130、快照创建时间 190、操作人 110、账号 120、IP 130(`components/AssignLogPanel.vue:1-43`)。
|
||||
|
||||
**挂号历史。** 字段依次为 ID 72、状态 100 Tag、患者 min 150(姓名+手机堆叠)、医生 110、医助 110、预约时间 min 130(日期+时段堆叠)、预约类型 100、渠道 110、确认 92、处方 80、备注 min 100、创建时间 165(`components/AppointmentRecordPanel.vue:1-75`)。堆叠单元垂直 gap 2 px、行高 1.35(`AppointmentRecordPanel.vue:172-178`)。
|
||||
|
||||
### 6.6 `components/` 全目录的可达性边界
|
||||
|
||||
三主视图的实际 import 清单见 `edit.vue:787-805` 与 `readonly.vue:151-167`。为避免把目录中保留组件误认成当前详情功能,本次也核了其余文件:
|
||||
|
||||
| 组件 | 三主视图当前可达性 | 结论 |
|
||||
|---|---|---|
|
||||
| `PatientInfoCard`、`PatientCaseCard` | readonly 直接使用 | 当前只读摘要/病例 |
|
||||
| `DailyMatrix`、`CaseRecordList`、`PatientOrderList`、`CallRecordPanel`、`ImChatRecordPanel`、`AssignLogPanel`、`AppointmentRecordPanel` | edit/readonly 直接使用(依页面和权限而异) | 当前主链 |
|
||||
| `RecordingPlaybackBlock` → `RecordingVideoPlayer` | CallRecordPanel 间接使用 | 当前视频主链(`CallRecordPanel.vue:32,78`; `RecordingPlaybackBlock.vue:5,35`) |
|
||||
| `DiagnosisTodoList` | DailyMatrix 间接使用 | 当前日常记录主链 |
|
||||
| `TrackingNoteTimeline` | edit 有 import,但模板区整段注释 | 当前不可见(`edit.vue:644-654,803`) |
|
||||
| `BloodRecordList` | 未被三主视图 import | 旧/独立颗粒列表;其能力已由 DailyMatrix 内置 600 px 血糖血压 Dialog 承接(`BloodRecordList.vue:1-170`; `DailyMatrix.vue:131-185`) |
|
||||
| `DietRecordList` | 未被三主视图 import | 旧/独立列表;当前 DailyMatrix 内置 800 px 饮食 Dialog(`DietRecordList.vue:1-112`; `DailyMatrix.vue:187-227`) |
|
||||
| `ExerciseRecordList` | 未被三主视图 import | 旧/独立列表;当前 DailyMatrix 内置 600 px 运动 Dialog(`ExerciseRecordList.vue:1-86`; `DailyMatrix.vue:229-260`) |
|
||||
| `TrackingMatrix` | 未被三主视图 import | 非当前矩阵;当前入口明确 import `DailyMatrix`(`edit.vue:796`; `readonly.vue:161`) |
|
||||
| `AssistantWatchCallDialog` | 未被三主视图 import | 旁路观看 Dialog,不属于诊单详情/编辑/预约;自身是 auto-fill、最小 280 px 的观看网格,gap 12 px(`AssistantWatchCallDialog.vue:1-19,242-282`) |
|
||||
|
||||
因此 PySide 对齐应以“当前可达主链”为实现范围,不能因为同目录存在旧颗粒列表而重复增加血糖、饮食、运动 Tab,也不能把观看 Dialog 或 TrackingMatrix 插入诊单详情。
|
||||
|
||||
---
|
||||
|
||||
## 7. 加载、空态、错误和权限状态
|
||||
|
||||
| 场景 | 后台表现 | 源码 |
|
||||
|---|---|---|
|
||||
| 独立详情加载 | 页面级 loading | `readonly.vue:1-2` |
|
||||
| 独立详情失败 | Hero 下方错误空态卡 | `readonly.vue:31-36` |
|
||||
| 无未服务记录 | Hero 灰色“从未记录”状态 | `readonly.vue:323-355` |
|
||||
| 抽屉只读 | 状态 badge + disabled fieldset + 无保存 | `edit.vue:11-54,57-84,766-781` |
|
||||
| 患者隐私锁定 | warning Alert,身份字段不可改 | `edit.vue:71-84` |
|
||||
| Tab 失权 | 隐藏 Tab;当前失权时回 basic | `edit.vue:655-759,883-913` |
|
||||
| 预约未选医生 | 时间区提示先选医生 | `appointment.vue:100-171` |
|
||||
| 无排班/无时段 | 时间区空态 | `appointment.vue:100-171` |
|
||||
| 当天已有预约 | warning 且禁止确认 | `appointment.vue:10-18,359-386` |
|
||||
| 其他日期已有预约 | info 提示,不等同当天阻断 | `appointment.vue:359-375` |
|
||||
| 表格无数据 | 各组件 Element empty/自定义空态 | `PatientOrderList.vue:1-6`; `CallRecordPanel.vue:55-74` |
|
||||
|
||||
操作权限应控制“是否渲染/是否可编辑”,而不是只把无权按钮做成 disabled;这正是 Tab 通过 `v-if` 实现的模式。
|
||||
|
||||
---
|
||||
|
||||
## 8. PySide6 组件映射
|
||||
|
||||
### 8.1 结构映射
|
||||
|
||||
| Vue / Element 结构 | PySide6 建议 | 必须保留的视觉行为 |
|
||||
|---|---|---|
|
||||
| `el-drawer` 右侧抽屉 | 模态 `QDialog` + 右侧面板,或主窗口上的遮罩 `QFrame` + 固定宽面板 | 桌面约 60% 宽;edit 在窄屏切全宽;Header/Body/Footer 三段分离 |
|
||||
| Drawer body | `QScrollArea` + body widget | 只让 body 滚动,footer 不随滚动 |
|
||||
| Drawer footer slot | 独立 `QFrame` + `QHBoxLayout` | 上边框、白底、固定在底部;不要放进 ScrollArea |
|
||||
| `el-tabs` | `QTabWidget` / 自定义 `QTabBar + QStackedWidget` | 可横向滚动、3 px active indicator、按 canonical permission 移除页签 |
|
||||
| `el-form` + `el-row/el-col` | `QFormLayout` 套 `QGridLayout` | 桌面 160 px 编辑标签 / 100 px 预约标签;窄屏改上标签单列 |
|
||||
| input/select/date/input-number | `QLineEdit`、`QComboBox`、`QDateEdit`、`QSpinBox/QDoubleSpinBox` | 常规高 32 px、圆角 10 px;footer 按钮 40/44 px |
|
||||
| radio/checkbox button groups | `QButtonGroup` + 可换行 FlowLayout | 12 px 字号,组间 12–16 px;小窗必须换行 |
|
||||
| textarea | `QTextEdit/QPlainTextEdit` | 2/3/4 行按源码用途设置 minimumHeight,而非无限增高 |
|
||||
| alert / empty | `QFrame + QLabel` / 统一 EmptyState | warning/info/error 色语义与文案位置一致 |
|
||||
| card | `QFrame` | 白底、1 px `#e6ebf2`、14 px radius、18 px padding |
|
||||
| table | `QTableView + QAbstractTableModel` | stripe、固定/最小列宽、状态 `QStyledItemDelegate` tag、右侧动作列 |
|
||||
| timeline | `QListView` 或纵向 QWidget 列表 + paintEvent 轴线 | 22 px 左缩进、2 px 轴、10 px node,图片缩略图 64 px |
|
||||
| appointment slot grid | `QScrollArea + FlowLayout` / `QGridLayout` 动态列 | cell min 110 × 70;选中/禁用/可用状态显式 |
|
||||
| loading | overlay `QFrame` + spinner/文字 | 遮住当前内容但保留布局尺寸,异步返回后解除 |
|
||||
|
||||
### 8.2 推荐常量(来自源码,不是重新设计)
|
||||
|
||||
```text
|
||||
FONT_FAMILY = "PingFang SC, Arial, Hiragino Sans GB, Microsoft YaHei"
|
||||
FONT_BASE = 14
|
||||
FONT_FORM = 12
|
||||
CONTROL_HEIGHT = 32
|
||||
FOOTER_BUTTON_HEIGHT = 40 # <=768 对应布局时 44
|
||||
CARD_RADIUS = 14
|
||||
CARD_PADDING = 18
|
||||
CONTROL_RADIUS = 10
|
||||
PANEL_GAP = 16
|
||||
BORDER = #e6ebf2
|
||||
BODY_BG = #f8fafc
|
||||
PAGE_BG = #f6f6f6
|
||||
TEXT_PRIMARY = #333333
|
||||
TEXT_REGULAR = #666666
|
||||
TEXT_MUTED = #999999
|
||||
PRIMARY = #409eff # 继承 Element 语义主色
|
||||
READONLY_ACCENT = #2563eb
|
||||
READONLY_ACCENT_STRONG = #1d4ed8
|
||||
```
|
||||
|
||||
### 8.3 PySide 字段装配顺序
|
||||
|
||||
为避免实现时按数据模型字母序排字段,应按以下 UI 顺序装配:
|
||||
|
||||
```text
|
||||
编辑病历:
|
||||
诊单ID
|
||||
→ 姓名 / 身份证
|
||||
→ 手机 / 性别
|
||||
→ 年龄
|
||||
→ [生命体征] 婚姻 / 身高 / 体重
|
||||
→ 地区 / 高压 / 低压
|
||||
→ 空腹血糖
|
||||
→ 诊断类型
|
||||
→ 状态 / 渠道
|
||||
→ 统计端就诊卡
|
||||
→ 在用药物
|
||||
→ [主诉] 当地诊断日期 / 糖尿病病史
|
||||
→ 当地医院诊断结果
|
||||
→ 当地医院名称
|
||||
→ [现病史] 口腔 → 饮水 → 体重变化 → 脂肪肝 → 饮食
|
||||
→ 肢体 → 睡眠 → 眼睛 → 头部 → 出汗 → 皮肤 → 小便 → 大便 → 腰肾
|
||||
→ 其他补充
|
||||
→ [既往史] 既往史
|
||||
→ [其他病史] 外伤 / 手术 / 过敏 → 家族 / 妊娠
|
||||
→ [诊断信息] 病史补充
|
||||
|
||||
预约:
|
||||
上次面诊 → 预约方式 → 预约类型 → 预约患者 → 渠道来源
|
||||
→ [条件] 自媒体详情 → 预约医生 → 日期 → 时段 → 备注
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 验收清单
|
||||
|
||||
1. 独立 readonly 是纵向卡片流,不出现 Tabs;抽屉 viewOnly 才是 Tabs + disabled 表单。
|
||||
2. 编辑 Drawer 桌面 60%,<=768 等效状态下全宽;预约 Drawer 源码没有该响应式覆盖,需明确属于适配增强。
|
||||
3. 编辑桌面 label 160 px,预约 label 100 px;编辑移动端 label 在控件上方。
|
||||
4. 编辑的 body 独立滚动,footer 始终可见;实现中没有把按钮放到滚动区末尾。
|
||||
5. Tab 顺序与 canonical permission 一致;“跟踪备注”不渲染。
|
||||
6. 编辑病历字段严格按 §4.5;不把脚本中未渲染字段自行加入。
|
||||
7. 只读病例按 4/3 列网格及 §3.4 顺序;小窗若做降列,需视为桌面端增强而非源码既有行为。
|
||||
8. 预约状态至少覆盖:未选医生、无排班、可约、不可约、选中、当天冲突、其他日期提示、loading。
|
||||
9. 订单、备注、视频、聊天、指派历史、挂号历史的列顺序/动作显隐与 §6 一致。
|
||||
10. 所有表格提供 loading、empty、error;权限失去时移除入口并将当前页回退到可见页。
|
||||
|
||||
以上规格记录的是当前后台源码的可见事实;PySide 可为桌面小窗增加降列和全宽适配,但不得改变字段顺序、权限门槛、状态语义或主操作位置。
|
||||
@@ -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。
|
||||
@@ -0,0 +1,155 @@
|
||||
# Diagnosis 最终视觉发布门禁
|
||||
|
||||
审计日期:2026-08-11
|
||||
审计性质:第四次独立、只读、最终视觉发布门禁;除更新本报告外,未修改业务源码、测试或 PNG。
|
||||
基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/` 及其主链直接依赖组件。
|
||||
当前截图:`D:/web/zyt/app/artifacts/diagnosis_visual/`。
|
||||
Trellis:`D:/web/zyt/app/.trellis` 不存在(`Test-Path` 为 `False`),因此无可补读的 Trellis workflow/spec。
|
||||
审计口径:只有与基准达到同型才记 PASS;Qt 与浏览器字体栅格化造成的轻微像素差不算结构问题。
|
||||
|
||||
## 最终结论
|
||||
|
||||
**PASS。**
|
||||
|
||||
- 共逐张按原始像素尺寸检查 **57 张 PNG**。
|
||||
- 逐图判定:**57 PASS / 0 PARTIAL / 0 不可读 / 0 硬裁切到窗口外**。
|
||||
- 严重度:**P0 = 0,P1 = 0,P2 = 0**。
|
||||
- 旧审计中的 Notes 真实缩略图、Chat 图片缩略图、Daily 局部工具栏、1200 px 连续处方 Drawer、订单 80% 详情 Drawer、订单 offset、Shell 与预约均已闭合或保持通过。
|
||||
- 上轮唯一 P1——视频表内播放器被压成 16 px 黑条——已关闭。`diagnosis_video_inline_player_1200x560.png` 与 `diagnosis_state_video_replay_1024x640.png` 均显示完整加载遮罩、播放按钮、进度条、时间、外部打开、独立窗口和备用地址;播放器本体运行时高度为 158 px,未超过基准 180 px 上限。
|
||||
|
||||
尺寸分布(按 PNG 实际像素而非文件名):`1024x640` 24 张、`1024x768` 1 张、`1200x560` 1 张、`1200x900` 4 张、`1280x800` 11 张、`1440x900` 13 张、`650x620` 1 张、`820x560` 1 张、`920x780` 1 张。
|
||||
|
||||
第四次增量完整性:目录仍为 57 张;相对上一门禁仅 3 张视频 PNG 更新(`diagnosis_video_inline_player_1200x560.png`、`diagnosis_state_video_replay_1024x640.png`、`diagnosis_state_video_upload_action_1024x640.png`)。本轮已对这 3 张全部按原始尺寸复核,并原尺寸抽查 Notes、Chat、Daily、处方四段连续态、订单 80% Drawer/offset、Shell、预约及 1024/1440 主列表;其余非视频 PNG 时间戳未变化,沿用上一轮全量逐图结论。
|
||||
|
||||
## 此前 OPEN 逐项复核
|
||||
|
||||
| 旧 OPEN | 终态 | 视觉与结构证据 |
|
||||
|---|---|---|
|
||||
| Notes 舌象真实缩略图 | **CLOSED** | `diagnosis_state_notes_actions_1024x640.png` 已显示实际 64x64 舌象位图,并有右上删除入口;当前 `_RemoteImageButton` 以 cover 方式加载到 `QSize(64, 64)`:`src/doctor_workstation/ui/diagnosis_drawer.py:2364-2404`。基准为真实 `el-image` 64x64 cover:`D:/web/zyt/admin/src/views/patient/reception/components/NoteTimeline.vue:52-69`。 |
|
||||
| Chat 图片消息缩略图 | **CLOSED** | `diagnosis_state_chat_archive_1024x640.png` 的患者图片消息直接显示实际图像缩略图,不再是“查看图片”文字链接;当前最大 `240x200`:`diagnosis_drawer.py:2553-2573`。基准:`ImChatRecordPanel.vue:39-50`。 |
|
||||
| Daily 待办局部工具栏 | **CLOSED** | `diagnosis_state_daily_lower_1024x640.png` 同时显示 `+ 新增待办`、全部/待执行/已发送/失败/已取消及局部“刷新”,无遮挡;当前:`diagnosis_drawer.py:1725-1761`,基准:`DiagnosisTodoList.vue:3-20`。顶部动作行也继续在 `diagnosis_state_daily_1024x640.png` 完整换行。 |
|
||||
| 处方 1200 px 连续 Drawer | **CLOSED** | `diagnosis_state_prescription_editor_1200x900.png`、`..._herbs_1200x900.png`、`..._usage_1200x900.png`、`..._signature_1200x900.png` 连续证明患者、诊断、RP 药材、用法、签名处于同一纵向滚动流,固定 footer 稳定;1024/920 窄宽度采用可用宽度而非截断。当前 `DRAWER_WIDTH = 1200` 且五段连续加入同一 `QScrollArea`:`src/doctor_workstation/ui/dialogs/prescription.py:1467,1491-1556,1902-1914`。基准:`D:/web/zyt/admin/src/components/tcm-prescription/index.vue:2-216,217-531`。旧 4-tab modal 结构已消失。 |
|
||||
| 订单 80% 详情 Drawer | **CLOSED** | `diagnosis_order_detail_drawer_1440x900.png` 的 panel 从 x=288 开始、宽 1152(80%);`diagnosis_state_order_detail_640x540.png` 与 `diagnosis_state_order_detail_drawer_1024x640.png` 的实际像素均为 1024x640,panel 从 x=205 开始、宽 819(约 80%)。金额概览、处方详情、关联收款在首屏可见;当前连续构建收款、履约与收货、物流轨迹、操作日志:`src/doctor_workstation/ui/dialogs/diagnosis.py:669-710,3623-4065`。基准共享 readonly Drawer:`PrescriptionOrderDetailDrawer.vue:13-20,154-220,433-545,550-824`。旧 `640x540` 仅是遗留文件名,不是当前截图尺寸。 |
|
||||
| 订单 offset 文案与解释层级 | **CLOSED** | `diagnosis_state_order_offset_1024x640.png` 已为“复诊统计起始偏移”+ 问号帮助 +“第 1 笔实单计为三诊”+“保存”,与基准同型;当前:`diagnosis.py:1384-1416,3001-3058`,基准:`PatientOrderList.vue:7-41`。 |
|
||||
| 视频多地址与逐条上传 | **CLOSED(能力/动作)** | `diagnosis_state_video_upload_action_1024x640.png` 的行级“追加回放”可见;当前每行绑定精确 `call_record_id`:`diagnosis.py:3304-3342`。备用地址结构存在于 `diagnosis_media.py:358-442`。 |
|
||||
| 视频表内播放器 | **CLOSED** | 两张终态图均显示完整可操作的表内播放器与备用地址;第四次运行时复核在 1200x560、1024x640 宿主中均测得 row 243 px、cell 242 px、player 158 px,controls 全部可见。详见下节。 |
|
||||
| Shell | **CLOSED / 无回归** | `diagnosis_shell_1024x640.png` 与 `diagnosis_shell_1440x900.png` 均保持 183 px sidebar、50 px topbar、40 px tabs,操作区及固定列无错位。 |
|
||||
| 预约 Drawer | **CLOSED / 无回归** | 8 张预约 PNG 的 60% Drawer、固定 footer、时段网格、empty/error/loading/refreshing/focus 状态均完整。 |
|
||||
|
||||
## P0 / P1 / P2
|
||||
|
||||
### P0
|
||||
|
||||
**0 项。** 未发现窗口级硬裁切、核心信息完全不可见、Drawer 比例失效、footer 滚走、二维码不可辨识、Shell 壳层错位或预约主流程不可操作的发布阻断。
|
||||
|
||||
### P1
|
||||
|
||||
**0 项。** 上轮唯一 P1“视频表内播放器被裁成 16 px 黑条”已关闭。
|
||||
|
||||
#### 视频表内播放器关闭证据
|
||||
|
||||
精确截图:
|
||||
|
||||
- `D:/web/zyt/app/artifacts/diagnosis_visual/diagnosis_video_inline_player_1200x560.png`
|
||||
- `D:/web/zyt/app/artifacts/diagnosis_visual/diagnosis_state_video_replay_1024x640.png`
|
||||
|
||||
两张图在原始尺寸中均呈现完整播放器:黑色视频 surface 内有“预览待加载 / 点击播放开始加载”,下方可见“播放”、进度条、`00:00 / 00:00`、“外部打开”和“独立窗口”;播放器下方继续显示“备用地址”及 `COS HLS 1`、`链接 2`,第二条无回放记录也保持独立普通行。1200 与 1024 宿主均无裁切、重叠或 controls 遗失。
|
||||
|
||||
第四次运行时几何复核(使用与最终截图相同的 `RenderRepository` 与两行 fixture,未写 PNG)在 1200x560 和 1024x640 两种宿主下得到一致结果:
|
||||
|
||||
- table row 0:243 px;可视 cell rect:242 px;`RecordingPlaybackCell`:242 px。
|
||||
- `InlineRecordingPlayer`:158 px,满足实现的 158–180 px 约束,也满足基准 `max-height: 180px`。
|
||||
- 播放按钮、slider、时间标签及整个 player 均 `visible=True`。
|
||||
- 备用地址使外层 cell 高于 180 px 是正确行为;180 px 上限只约束播放器本体,不约束播放器 + 备用地址的整行总高。
|
||||
|
||||
当前源码的闭环点:
|
||||
|
||||
- `src/doctor_workstation/ui/diagnosis_media.py:179-278` 构建 158–180 px 内嵌 player、视频 surface 与完整控制栏。
|
||||
- `diagnosis_media.py:359-457` 将 player、备用地址组成定高 cell,并由 `required_table_row_height()` 返回真实所需高度。
|
||||
- `src/doctor_workstation/ui/dialogs/diagnosis.py:3304-3367` 在 Qt 的 `resizeRowsToContents()` 后,以 cell 实际 hint 恢复 row 高度并写入 item size hint,避免另一列固定高按钮再次压扁整行。
|
||||
|
||||
基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/components/RecordingPlaybackBlock.vue:1-30` 在“录制回放”单元格内直接放置 `RecordingVideoPlayer`;`RecordingVideoPlayer.vue:2-13,320-329` 显示 controls、16:9、最大高 180 px 的完整播放器。当前终态在结构、信息层级和可操作性上已同型,因此关闭 P1。
|
||||
|
||||
### P2
|
||||
|
||||
**0 项。** 上一轮两项 P2 已闭合:Daily 待办已具精确 `+ 新增待办` 与局部刷新;订单 offset 已具精确标题、帮助入口、动态解释与“保存”文案。
|
||||
|
||||
## 57 张 PNG 逐张判定
|
||||
|
||||
### 预约 Drawer(8/8 PASS)
|
||||
|
||||
| PNG | 判定 | 原始尺寸下目检结论 |
|
||||
|---|---|---|
|
||||
| `appointment_drawer_1024x640.png` | PASS | 60% Drawer、body 滚动、时段状态与固定 footer 完整。 |
|
||||
| `appointment_drawer_1440x900.png` | PASS | 医生、日期、时段网格、备注与 footer 对齐。 |
|
||||
| `appointment_drawer_empty_doctors_1440x900.png` | PASS | warning、空医生状态与禁用确认完整。 |
|
||||
| `appointment_drawer_empty_roster_1440x900.png` | PASS | 无排班 warning、医生选择与空态完整。 |
|
||||
| `appointment_drawer_error_1440x900.png` | PASS | 号源错误 banner、刷新入口、空态与禁用确认完整。 |
|
||||
| `appointment_drawer_keyboard_focus_1440x900.png` | PASS | 日期按钮 focus ring 清晰,不挤压布局。 |
|
||||
| `appointment_drawer_loading_1440x900.png` | PASS | 初始 loading overlay、spinner、文案与 footer 完整。 |
|
||||
| `appointment_drawer_refreshing_1440x900.png` | PASS | 刷新 overlay、spinner、文案与 footer 完整。 |
|
||||
|
||||
### 诊单列表、壳层与主 Drawer(23/23 PASS)
|
||||
|
||||
| PNG | 判定 | 原始尺寸下目检结论 |
|
||||
|---|---|---|
|
||||
| `diagnosis_1024x640.png` | PASS | 窄屏筛选换行、横向滚动、右 460 px 固定区与行语义完整。 |
|
||||
| `diagnosis_1440x900.png` | PASS | 宽屏列密度、复合挂号格、业务色与固定操作区成立。 |
|
||||
| `diagnosis_advanced_filters_1280x800.png` | PASS | 高级筛选全量展开,无控件裁切。 |
|
||||
| `diagnosis_double_appointment_cancel_1280x800.png` | PASS | 同诊单双挂号各有取消入口。 |
|
||||
| `diagnosis_edit_1024x640.png` | PASS | 60% Drawer、tab、独立滚动与固定 footer 成立。 |
|
||||
| `diagnosis_edit_1440x900.png` | PASS | 864 px Drawer 与宽屏字段比例稳定。 |
|
||||
| `diagnosis_empty_1280x800.png` | PASS | 表头、空态、横向滚动与 0 条分页保留。 |
|
||||
| `diagnosis_error_1280x800.png` | PASS | 错误态清晰,无旧数据穿透。 |
|
||||
| `diagnosis_focus_1280x800.png` | PASS | 搜索 focus ring 清晰且不挤压布局。 |
|
||||
| `diagnosis_full_menu_1280x800.png` | PASS | 8 项菜单、图标、danger 删除与“二维码”均完整。 |
|
||||
| `diagnosis_horizontal_scroll_1024x640.png` | PASS | 主表横移时右 460 px 固定区不动。 |
|
||||
| `diagnosis_hover_warning_1280x800.png` | PASS | warning 行 hover 与左侧强调条连续。 |
|
||||
| `diagnosis_loading_1280x800.png` | PASS | 表内 spinner 与淡化内容完整。 |
|
||||
| `diagnosis_order_detail_drawer_1440x900.png` | PASS | 80% Drawer、金额概览、处方详情、收款记录与固定 footer 成立。 |
|
||||
| `diagnosis_order_qrcode_1280x800.png` | PASS | QR 可辨、订单号、状态、只读 URL 与动作完整。 |
|
||||
| `diagnosis_pending_assign_1280x800.png` | PASS | 待分配医助、月份与搜索处于同一流式区域。 |
|
||||
| `diagnosis_permissions_cropped_1280x800.png` | PASS | 权限收口后只保留查看,无敏感动作残留。 |
|
||||
| `diagnosis_readonly_1024x640.png` | PASS | 独立只读页 hero、病例卡与窄屏字段无拆字。 |
|
||||
| `diagnosis_readonly_1440x900.png` | PASS | 4 列病例密度、异常指标与长页滚动成立。 |
|
||||
| `diagnosis_shell_1024x640.png` | PASS | 183/50/40 壳层、tabs、固定列及分页成立。 |
|
||||
| `diagnosis_shell_1440x900.png` | PASS | 宽屏壳层比例及顶栏图标/中文稳定。 |
|
||||
| `diagnosis_viewonly_1024x640.png` | PASS | 60% 只读 Drawer、警告、disabled 表单与仅关闭 footer 成立。 |
|
||||
| `diagnosis_viewonly_1440x900.png` | PASS | 宽屏只读栅格、全 tabs 与固定 footer 成立。 |
|
||||
|
||||
### 详情状态与子流程(26/26 PASS)
|
||||
|
||||
| PNG | 判定 | 原始尺寸下目检结论 |
|
||||
|---|---|---|
|
||||
| `diagnosis_state_chat_archive_1024x640.png` | PASS | info、归档同步、左右气泡与真实图片缩略图成立。 |
|
||||
| `diagnosis_state_daily_1024x640.png` | PASS | Daily 动作完整换行,无裁切。 |
|
||||
| `diagnosis_state_daily_blood_edit_650x620.png` | PASS | 血糖/血压字段、单位、必填说明与固定动作完整。 |
|
||||
| `diagnosis_state_daily_lower_1024x640.png` | PASS | `+ 新增待办`、filters、局部刷新、状态与横向滚动完整。 |
|
||||
| `diagnosis_state_empty_1024x640.png` | PASS | 处方空态与 primary“开方”成立。 |
|
||||
| `diagnosis_state_error_1024x640.png` | PASS | danger banner、重试、空表单与禁用保存完整。 |
|
||||
| `diagnosis_state_focus_1024x640.png` | PASS | 姓名 focus ring、close 与固定 footer 完整。 |
|
||||
| `diagnosis_state_loading_1024x640.png` | PASS | loading overlay、spinner 与文案完整。 |
|
||||
| `diagnosis_state_notes_actions_1024x640.png` | PASS | 新增/上传动作、实际舌象缩略图、报告 chip 与单附件删除完整。 |
|
||||
| `diagnosis_state_order_detail_640x540.png` | PASS | 实际为 1024x640 的 80% Drawer;金额与处方首屏、滚动与 footer 成立。 |
|
||||
| `diagnosis_state_order_detail_drawer_1024x640.png` | PASS | 80% Drawer 窄屏终态与上一张一致且无结构裁切。 |
|
||||
| `diagnosis_state_order_offset_1024x640.png` | PASS | 精确标题、帮助、动态解释、“保存”、列表与分页成立。 |
|
||||
| `diagnosis_state_permission_1024x640.png` | PASS | 获权 tabs、敏感字段掩码与仅关闭 footer 成立。 |
|
||||
| `diagnosis_state_prescription_editor_1024x768.png` | PASS | 窄屏连续 Drawer 首段、RP 工具栏、滚动与固定 footer 成立。 |
|
||||
| `diagnosis_state_prescription_editor_1200x900.png` | PASS | 1200 px 连续 Drawer 患者/诊断/RP 起始结构成立。 |
|
||||
| `diagnosis_state_prescription_editor_920x780.png` | PASS | 窄宿主按可用宽度适配,旧 tabbed modal 已消失。 |
|
||||
| `diagnosis_state_prescription_editor_herbs_1200x900.png` | PASS | 主方/辅方网格、剂量与删除入口处于同一滚动流。 |
|
||||
| `diagnosis_state_prescription_editor_signature_1200x900.png` | PASS | 辅方用法、医生签名板及固定 footer 成立。 |
|
||||
| `diagnosis_state_prescription_editor_usage_1200x900.png` | PASS | 主/辅方用法层级、说明字段与连续滚动成立。 |
|
||||
| `diagnosis_state_save_failure_1024x640.png` | PASS | 红色失败态与文案完整。 |
|
||||
| `diagnosis_state_save_loading_1024x640.png` | PASS | 按钮内 spinner 与“正在保存”完整。 |
|
||||
| `diagnosis_state_save_success_1024x640.png` | PASS | 绿色成功态与文案完整。 |
|
||||
| `diagnosis_state_video_player_820x560.png` | PASS | 独立播放器作为兼容兜底本身完整;不替代表内播放器要求。 |
|
||||
| `diagnosis_state_video_replay_1024x640.png` | PASS | 158 px 表内 player、加载态、完整 controls 与备用地址均可见,第二条普通行未受高行影响。 |
|
||||
| `diagnosis_state_video_upload_action_1024x640.png` | PASS | 横移后 243 px 高视频行仍保持状态、录制与“追加回放”垂直居中,第二条普通行未被错误拉高。 |
|
||||
| `diagnosis_video_inline_player_1200x560.png` | PASS | 宽屏完整显示 158 px 表内 player、controls、备用地址及相邻列,无裁切。 |
|
||||
|
||||
## 发布判定
|
||||
|
||||
- 旧审计的 Notes、Chat、Daily、处方、订单、视频、Shell、预约问题已经全部闭合或保持通过。
|
||||
- 两张重新录制的视频原始尺寸 PNG 与运行时实际控件几何相互印证,旧唯一 P1 已关闭。
|
||||
- 当前最终门禁:**PASS(57 PASS / 0 PARTIAL,共检查 57 张 PNG;P0=0,P1=0,P2=0)。**
|
||||
@@ -0,0 +1,373 @@
|
||||
# 管理端“诊单列表”视觉规格(源码基准)
|
||||
|
||||
> 审计日期:2026-08-10
|
||||
> 唯一业务页面基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue`
|
||||
> 方法:只读检查 Vue 模板、同文件 scoped SCSS、直接挂载组件、全局样式、主题生成逻辑和当前锁定的 Element Plus 源码;未启动浏览器、未改业务源码。
|
||||
|
||||
## 1. 结论摘要
|
||||
|
||||
1. 该页面**没有页面内标题、说明文案或独立标题栏**。路由内容从一张无边框、无阴影的筛选卡直接开始;“新增诊单”等主操作位于第二张列表卡顶部,而不是页面标题旁。证据:`index.vue:2-154`。
|
||||
2. 页面是“浅灰页面底 + 两张白卡”的高密度后台列表。外层路由容器提供左右 `8px`、上下 `16px` 的留白;筛选卡和列表卡之间为 `12px`。证据:`D:/web/zyt/admin/src/layout/default/components/main.vue:2-10`、`index.vue:154`、`D:/web/zyt/admin/tailwind.config.js:103-113`。
|
||||
3. 顶部不是常规 tabs,而是可换行的圆角文字 chip:6 个日期入口,再接“待预约 / 已完成 / 待分配医助”,计数是文字后面的普通半透明数字,不是独立 badge。当天默认选中。证据:`index.vue:6-70,964-976,2174-2182,2222-2317`。
|
||||
4. 表格声明列的最小总宽约为 **1403px**;右侧固定“视频旁观” `120px` 与“操作” `340px`,合计 `460px`。窄于该宽度时由 Element Table 横向滚动,右侧两列保持 fixed。证据:`index.vue:191-342`。
|
||||
5. 表格使用斑马纹;普通偶数行是 `#FAFAFA`,普通悬停是 `#F8F8F8`。未确认、未挂号行还声明了左侧语义色和横向渐变,但静态渐变写在 `tr`,斑马纹及 fixed cell 的 `td` 背景可能遮住它;两类行的 hover 渐变直接写在 `td`,表现更稳定。证据:`index.vue:2723-2735`、Element Plus `table.scss:560-588,665-668`。
|
||||
6. 空态不是 `el-empty` 插画,而是表格默认的 **60px 高“暂无数据”文本区**;加载态是覆盖表格的半透明蒙层和 `42px` 主色环。证据:Element Plus `table.scss:52-70`、`loading.scss:19-73`、中文 locale `zh-cn.mjs:137`。
|
||||
|
||||
## 2. 源码证据层级
|
||||
|
||||
| 层级 | 文件 | 本规格使用内容 |
|
||||
|---|---|---|
|
||||
| 页面模板与局部样式 | `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:1-2766` | DOM 次序、列宽、状态类、局部尺寸和颜色 |
|
||||
| 路由内容容器 | `D:/web/zyt/admin/src/layout/default/components/main.vue:1-12` | 页面底色、外层滚动、`px-2 py-4` |
|
||||
| 共享组件 | `D:/web/zyt/admin/src/components/pagination/index.vue:1-50`;`D:/web/zyt/admin/src/components/daterange-picker/index.vue:1-49` | 分页布局、页容量、日期范围控件行为 |
|
||||
| 项目全局样式 | `D:/web/zyt/admin/src/styles/index.scss:1-5`、`var.css:1-49`、`element.scss:58-68,106-115,140-151,177-201`、`public.scss:1-3` | 全局字体、颜色、表格、输入焦点、移动分页 |
|
||||
| 项目主题 | `D:/web/zyt/admin/src/config/setting.ts:1-15`、`src/utils/theme.ts:3-75`、`src/App.vue:25-34` | 默认主色、明暗主题色派生、运行时覆盖 |
|
||||
| Tailwind token | `D:/web/zyt/admin/tailwind.config.js:5-155` | 字体族、颜色映射、间距值 |
|
||||
| UI 框架 | `D:/web/zyt/admin/pnpm-lock.yaml:47-49,2412-2415`;`node_modules/element-plus/theme-chalk/src/**` | 当前锁定 Element Plus 2.13.7 的默认组件尺寸 |
|
||||
|
||||
`package.json:27` 写的是兼容范围 `^2.9.4`,当前 lock 与已安装源码均为 **2.13.7**;本规格的框架默认尺寸以锁文件和当前安装源码为准。
|
||||
|
||||
## 3. 页面骨架与 ASCII wireframe
|
||||
|
||||
### 3.1 宽屏主态
|
||||
|
||||
```text
|
||||
主内容滚动区,#F6F6F6;左右 8,上下 16
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 筛选卡 #FFF / r4 / 无边框无阴影;内边距 12V × 16H │
|
||||
│ [昨天挂号 n] [前天挂号 n] [当天挂号 n] [明天挂号 n] [后天挂号 n] [全部 n] │
|
||||
│ [待预约 n] [已完成 n] [待分配医助 n] [患者姓名 / 手机号 160] [查询 32h] │
|
||||
│ ──────────────────────────────────────────────────────────────────────────────────────── │
|
||||
│ 挂号:[全部] [已挂号] [未挂号] │ 确认:[全部] [已确认] [未确认] 更多筛选⌄ │
|
||||
│ · · · 展开时:诊断类型 / 证型 / 医助 / 最近挂号日期 / 最近指派日期 / 渠道 / 重置 · · · │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
12px
|
||||
┌────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ [新增诊单] [批量指派医助] [批量取消指派] 已选 n 条(按需显示) │
|
||||
├────┬──────┬──────┬───────────┬──────────────┬──────┬────────┬──────┬──────┬──────────┬──────┬────────────────────┤
|
||||
│ □ │ ID │ 患者 │ 性别/年龄 │ 挂号 │ 确认 │ 复诊 │ 助理 │ 开方 │未服务天数│旁观* │ 操作* │
|
||||
├────┼──────┼──────┼───────────┼──────────────┼──────┼────────┼──────┼──────┼──────────┼──────┼────────────────────┤
|
||||
│ □ │123 NEW│张三 │ 男 · 42岁 │[待问诊] 医生 │未确认│日期/医生│医助 │已开方│ 8 │进入 │查看 诊单 开方 … 更多│
|
||||
│ │ │ │ │时间 / 渠道 │ │ │ │ │ │ │ │
|
||||
├────┴──────┴──────┴───────────┴──────────────┴──────┴────────┴──────┴──────┴──────────┴──────┴────────────────────┤
|
||||
│ 共 n 条 [15条/页] [<] 1 2 … [>] 前往 [ ] 页 │
|
||||
└────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
* 右侧固定列,共 460px
|
||||
```
|
||||
|
||||
### 3.2 结构说明
|
||||
|
||||
- 外层 `main` 是整页垂直滚动容器;诊单表没有本页固定高度或纵向虚拟滚动,15 条数据会把页面自然撑高。证据:`layout/default/components/main.vue:2-11`、`index.vue:179-190`。
|
||||
- 两张卡都使用 `shadow="never"` 与 `!border-none`;保留 Element Card 的白底、裁切和 `4px` 圆角。框架默认:Element Plus `card.scss:9-17`、`common/var.scss:1226-1235`。
|
||||
- 页面根节点只设 `min-height:100%`、页面底色和水平居中;不设自身宽度、最大宽度或 padding。证据:`index.vue:2200-2206`。
|
||||
- 顶部全局 header、面包屑和多页签属于应用壳,不在 `index.vue` 中;它们是否出现受全局 setting 控制,不能当作本页标题复刻。
|
||||
|
||||
## 4. 可复刻的基础 token
|
||||
|
||||
### 4.1 字体与基础尺寸
|
||||
|
||||
| 项 | 源码确定值 | 证据 |
|
||||
|---|---:|---|
|
||||
| 字体族 | `PingFang SC, Arial, Hiragino Sans GB, Microsoft YaHei, sans-serif` | `tailwind.config.js:79-81`、`var.css:2` |
|
||||
| body / Element base | `14px` | `public.scss:1-3`、`var.css:16` |
|
||||
| small / extra-small | `13px / 12px` | `var.css:17-18` |
|
||||
| 默认控件高度 | `32px` | Element Plus `common/var.scss:233-241` |
|
||||
| small 控件高度 | `24px` | 同上 |
|
||||
| 默认圆角 | `4px` | Element Plus `base.css:1` |
|
||||
| 卡片圆角 | `4px` | Element Plus `common/var.scss:1227-1234` |
|
||||
|
||||
字体栅格的真实像素高度仍受 Windows 缩放、浏览器 DPR 与首个可用字体影响;页面 chip 没有固定 `height`/`line-height`,只能精确复刻 padding,不能仅凭源码承诺最终外框高度。
|
||||
|
||||
### 4.2 亮色主题
|
||||
|
||||
项目在 `App.vue` mount 时用 setting 重写 Element 语义色。默认主色不是 Element 原生蓝,而是 `#4A5DFF`。主题派生算法见 `src/utils/theme.ts:3-18,29-37,55-74`。
|
||||
|
||||
| token | 默认运行值 | 使用处 |
|
||||
|---|---|---|
|
||||
| `--el-color-primary` | `#4A5DFF` | active chip、主按钮、链接、loading |
|
||||
| primary light-3 / 5 / 7 / 8 / 9 | `#808EFF / #A5AEFF / #C9CEFF / #DBDFFF / #EDEFFF` | hover、focus、轻底色 |
|
||||
| primary dark-2 | `#3B4ACC` | active 状态;页面另有硬编码近似值 `#3A4ACC` |
|
||||
| success | `#67C23A` | 已完成 chip、已确认、成功按钮 |
|
||||
| warning | `#E6A23C` | 待分配、未确认、警示行 |
|
||||
| danger | `#F56C6C` | NEW、删除/取消 |
|
||||
| info | `#909399` | 待预约、完成预约、次要态 |
|
||||
| page / card | `#F6F6F6 / #FFFFFF` | 页面底 / 卡片与普通行 |
|
||||
| primary / regular / secondary text | `#333333 / #666666 / #999999` | 标题信息 / 常规 / 辅助 |
|
||||
| placeholder / disabled | `#A8ABB2 / #C0C4CC` | 空值、禁用 |
|
||||
| border / light / lighter | `#DCDFE6 / #E4E7ED / #EBEEF5` | 控件、卡线、行分隔 |
|
||||
| fill / light / lighter | `#F0F2F5 / #F8F8F8 / #FAFAFA` | hover、表头、斑马纹 |
|
||||
| loading mask | `rgba(255,255,255,.5)` | 表格加载蒙层 |
|
||||
|
||||
证据:`src/config/setting.ts:9-14`、`src/styles/var.css:9-43`。主色的派生结果使用项目同一 `css-color-function` 算法计算;不是页面硬编码。warning/info 的页面渐变使用 `--el-color-*-rgb`,其当前框架默认分别为 `230,162,60` 和 `144,147,153`(Element Plus `base.css:1`)。
|
||||
|
||||
### 4.3 暗色与运行时主题边界
|
||||
|
||||
- `.dark` 下页面底为 `#0A0A0A`、普通背景 `#1D2124`、overlay `#1D1E1F`,主/常规/次要文字为 `#E5EAF3 / #CFD3DC / #A3A6AD`,lighter border 为 `#363637`。证据:`src/styles/dark.css:1-31`。
|
||||
- `App.vue` 根据 `useDark()` 调用主题生成器,主色仍可保持 `#4A5DFF`,但 light-* 在暗色下改用 shade 算法。证据:`src/App.vue:29-34`、`src/utils/theme.ts:12-18,55-77`。
|
||||
- setting 会从本地缓存覆盖默认颜色,因此**某个真实账号的主色/语义色可能不同**。证据:`src/stores/modules/setting.ts:12-50,65-77`。上表是无用户自定义时的确定基线。
|
||||
- 页面内硬编码的 `#4A5DFF`、`#3A4ACC` 与 `rgba(74,93,255,...)` 不会随自定义主题变化,因而自定义主题下会出现两套蓝色。证据:`index.vue:2522-2529,2556-2559,2596-2599`。
|
||||
|
||||
## 5. 顶部筛选卡
|
||||
|
||||
### 5.1 卡与第一行
|
||||
|
||||
筛选卡 body padding 为 `12px 16px`。第一行 `.filter-main` 是单行 flex:垂直居中、两端对齐、组间 `16px`、下边距 `8px`;**自身没有 `flex-wrap`**。左侧 `.date-chips` 才可换行,chip 间距 `6px`。证据:`index.vue:3-76,2208-2225`。
|
||||
|
||||
| 元素 | 几何/排版 | 默认/交互颜色 | 证据 |
|
||||
|---|---|---|---|
|
||||
| 普通日期 chip | padding `6px 12px`;14px/500;r6;transition `.2s` | 常规 `#666` + `#F8F8F8`;hover `#F0F2F5`;active 白字 + 主色 | `index.vue:2227-2250` |
|
||||
| chip 计数 | 与标题相隔 `4px`;normal style;opacity `.8` | 跟随父 chip | `index.vue:23-24,2237-2241` |
|
||||
| 待预约 | 普通 chip 外再有左 margin `8px` | info / info-light-9;active info 实色 | `index.vue:26-32,2253-2265` |
|
||||
| 已完成 | 左 margin `8px` | success / success-light-9;active success 实色 | `index.vue:33-39,2268-2280` |
|
||||
| 待分配医助 | 权限控制;外容器可换行、gap `8px`、左 margin `8px` | warning / warning-light-9;active warning 实色 | `index.vue:40-70,2283-2308` |
|
||||
| 待分配月份 | default 月选择器;宽 `128px` | Element 默认输入态 | `index.vue:51-60,2310-2312` |
|
||||
| 待分配宽搜索 | default 输入;宽 `220px`,最大 `42vw` | Element 默认输入态 | `index.vue:61-69,2314-2317` |
|
||||
| 主搜索 | default 输入宽 `160px` | placeholder“患者姓名 / 手机号” | `index.vue:72-75,2320-2326` |
|
||||
| 查询 | primary default button;高 `32px`,14px,r4 | 白字/主色底 | `index.vue:74`、Element Plus `button.scss:22-43,76-82` |
|
||||
|
||||
日期 chip 的实际顺序是:**昨天、前天、当天、明天、后天、全部**,不是按自然日期升序;随后才是 3 个业务 chip。证据:`index.vue:964-976`。页面挂载时直接选中当天并请求列表;各 chip 计数延后到 idle(不支持时延后约 `120ms`)获取,所以首帧可能只有标签、稍后才出现数字。证据:`index.vue:1046-1095,2174-2182`。
|
||||
|
||||
### 5.2 第二行快捷筛选
|
||||
|
||||
- 第二行上方有 `1px solid #EBEEF5`;自身 `padding-top:8px`、`margin-top:8px`、水平 gap `6px`、垂直居中。证据:`index.vue:78-102,2329-2335`。
|
||||
- “挂号:”“确认:”为 `13px #999`,label 后额外 `2px`。每组都是“全部 / 已… / 未…”。证据:`index.vue:79-97,2337-2341`。
|
||||
- 小 chip 最终 padding 是 `.chip-sm` 的 `4px 10px`,13px、r4、透明底;hover/active 用主色文字与 primary-light-9,active 权重 500。证据:`index.vue:2343-2366`。
|
||||
- 两组之间为 `1×14px` 的 lighter 分隔线,左右 margin `4px`。证据:`index.vue:2368-2373`。
|
||||
- “更多筛选”是 primary/link/small 按钮,左 margin `8px`;展开时 ArrowDown 旋转 `180°`,文字变“收起”。源码没有为该旋转指定独立 transition。证据:`index.vue:98-101,2375-2380`。
|
||||
|
||||
### 5.3 展开筛选区
|
||||
|
||||
展开区使用 `v-show`,所以是即时显隐而非折叠动画。它为可换行 flex,gap `10px`,上 margin/padding 各 `8px`,顶部是 `1px dashed #EBEEF5`。证据:`index.vue:104-151,2384-2391`。
|
||||
|
||||
控件从左到右:
|
||||
|
||||
1. 诊断类型 select(small,`24px` 高);
|
||||
2. 证型 select(small);
|
||||
3. 医助 select(small,可搜索);
|
||||
4. 最近挂号日期范围(共享 `DaterangePicker`,`260px` 宽、最大 100%,未传 size,故 default `32px` 高);
|
||||
5. 最近指派日期范围(同上);
|
||||
6. 最近挂号渠道(small,可搜索,宽 `170px`);
|
||||
7. 重置(small button,`24px` 高)。
|
||||
|
||||
证据:`index.vue:104-150,2393-2405`;共享日期组件只是透传一个 `el-date-picker`,见 `components/daterange-picker/index.vue:1-11,15-31`。因此展开区客观上混有 `24px` 与 `32px` 两种控件高度。前三个 select 没有本页固定宽度;Element Select 自身是 `width:100%` 的可收缩 flex item,最终每行宽度依赖可用空间与浏览器 flex 计算,源码不能给出一个稳定像素值。
|
||||
|
||||
## 6. 列表卡操作栏
|
||||
|
||||
- 列表卡距筛选卡 `12px`,body padding 清零。证据:`index.vue:154,2408-2412`、Tailwind spacing `tailwind.config.js:103-113`。
|
||||
- 操作栏为左右两端布局,padding `10px 16px`,底边 `1px #EBEEF5`;未固定高度。default button 高 `32px`,故无换行时栏高至少约 `52px`。证据:`index.vue:155-177,2414-2429`。
|
||||
- 左侧依权限显示:primary“新增诊单”、success“批量指派医助”、warning/plain“批量取消指派”。后两者按选中状态禁用。右侧仅在选中数量大于 0 时显示 `13px #999` 的“已选 n 条”。证据:`index.vue:157-176`。
|
||||
- `.list-actions` 声明 `gap:8px`,但 Element 默认还有 `.el-button + .el-button { margin-left:12px }`,此处没有清零。因此相邻按钮的实际起点间空白通常是 **20px(8+12)**,而不是设计者表面写下的 8px。证据:`index.vue:2421-2424`、Element Plus `button.scss:72-74`。
|
||||
|
||||
## 7. 表格规格
|
||||
|
||||
### 7.1 外框与表头
|
||||
|
||||
- 表格外围 padding `10px 16px 0`;表格宽度 100%,默认 fixed layout、无竖边框;行之间由 `1px #EBEEF5` 底边分隔。证据:`index.vue:178-190,2432-2434`、Element Plus `table.scss:11-20,188-195,217-235`。
|
||||
- 表头底 `#F8F8F8`、13px/600、文字 `#666`。默认 cell 水平 padding `12px`,行高 `23px`,纵向 padding `8px`,所以单行表头的理论最小高度约 **39px**(23+16,不含亚像素/边线差异)。证据:`index.vue:2710-2716`、Element Plus `table.scss:156-195`、`common/var.scss:1014-1031`。
|
||||
- 表头“未服务天数”有双态排序箭头,顺序只允许 descending → ascending;激活箭头使用主色。证据:`index.vue:297-304`、Element Plus `table.scss:548-552`。
|
||||
|
||||
### 7.2 列宽与内容
|
||||
|
||||
| 次序 | 列 | 声明宽度 | 对齐/固定 | 主要视觉内容 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 选择 | `48px` | center | header 全选 + 行 checkbox |
|
||||
| 2 | ID | `min 70px` | left | ID 为 14px/600;未读指派时附 small/dark/danger `NEW` |
|
||||
| 3 | 患者 | `min 60px` | left | 患者名 14px/600;头像模板已注释,不显示 |
|
||||
| 4 | 性别 / 年龄 | `100px` | left | `男 · 42岁`,继承表格 14px |
|
||||
| 5 | 挂号 | `min 175px` | left | 预约状态 badge、医生、时间、可取消链接、最近渠道;无则“未挂号” |
|
||||
| 6 | 确认 | `88px` | center | 13px 已确认/未确认文字 |
|
||||
| 7 | 复诊 | `min 120px` | left | 时间、医生、可选“处方已作废” tag;无则“无” |
|
||||
| 8 | 助理 | `100px` | left | 13px 次要文字,溢出 tooltip |
|
||||
| 9 | 开方 | `72px` | center | 13px 已开方/未开方 |
|
||||
| 10 | 未服务天数 | `110px` | center | 13px/600 tabular number,按天数绿/橙/红;可有健康打卡 tooltip |
|
||||
| 11 | 视频旁观 | `120px` | center / right fixed | small link 或 12px 次要状态 |
|
||||
| 12 | 操作 | `340px` | left / right fixed | 5 个 small/link 主动作 + click dropdown |
|
||||
|
||||
证据:`index.vue:191-406`。声明宽度/最小宽度合计约 `1403px`;`min-width` 列在更宽窗口可继续分配余量。右固定区域为 `120+340=460px`,Element 会在滚动边缘加 fixed-column 阴影;阴影值来自框架 `common/var.scss:981-1000`。
|
||||
|
||||
### 7.3 行高与密度
|
||||
|
||||
- 本页再次把 body cell 设为 `padding:8px 0`,cell 内层保留 Element 的水平 `12px` 与 `23px` line-height。纯单行内容的最小行高约 **39px**。证据:`index.vue:2720-2722`、Element Plus `table.scss:188-195`。
|
||||
- 挂号 cell 有 `min-height:36px`、内 padding `4px 8px`、r6,所以含该列的常规数据行通常至少约 **52px**(cell 36 + table 上下 16);实际由内容较高者决定。证据:`index.vue:2485-2489`。
|
||||
- 同一诊单多条预约时,每一后续预约额外带 `8px` top margin、`8px` top padding 和 `1px dashed #EBEEF5`,行高会显著增加,不是固定行高。证据:`index.vue:227-247,2490-2494`。
|
||||
- 操作区允许换行;链接数量受权限与记录状态影响,所以操作列也可能增高整行。证据:`index.vue:344-403,2683-2688`。
|
||||
|
||||
### 7.4 斑马、hover 与业务高亮
|
||||
|
||||
| 状态 | 静态 | hover | 判定优先级 |
|
||||
|---|---|---|---|
|
||||
| 普通奇数行 | 白 `#FFF` | `#F8F8F8` | 无业务类 |
|
||||
| 普通偶数行 | `#FAFAFA` | `#F8F8F8` | `stripe` |
|
||||
| 未确认 | 预期左侧 `3px #E6A23C`;从左到右 warning RGB `.06 → transparent` | warning RGB `.10 → transparent`,直接覆盖 `td` | 第一优先 |
|
||||
| 已确认但未挂号 | 预期左侧 `3px #909399`;info RGB `.04 → transparent` | info RGB `.08 → transparent`,直接覆盖 `td` | 第二优先 |
|
||||
|
||||
判定函数先检查“是否确认”,只有已确认才检查“是否挂号”,所以一行不会同时得到两个业务类。证据:`index.vue:1313-1337`。页面声明见 `index.vue:2723-2735`;斑马和 hover 框架规则见 Element Plus `table.scss:560-588,665-668`。
|
||||
|
||||
实现时必须区分“源码意图”和“浏览器可见结果”:
|
||||
|
||||
- 静态业务渐变在 `tr`,而偶数斑马行和 fixed cell 的背景落在 `td`;不透明 `td` 会遮住下面的 `tr` 渐变。
|
||||
- 业务 hover 选择器直接命中 `td` 且带 `!important`,因此 hover 更可靠。
|
||||
- `border-left` 施加在 `tr`,在 `border-collapse:separate` 的 table 中具体像素绘制还受浏览器表格格式化上下文影响。
|
||||
|
||||
PySide 复刻应按“意图”直接给每个可见 cell/row delegate 绘背景和 3px 左条,避免复现 CSS 遮挡缺陷;若目标是截图逐像素一致,则需先用指定 Chromium、固定数据测一张运行态截图。
|
||||
|
||||
### 7.5 单元格内部视觉
|
||||
|
||||
#### 患者与 ID
|
||||
|
||||
- `.patient-name` 为 14px/600、`#333`、line-height `1.3`。ID 与患者列都复用该样式。证据:`index.vue:194-212,2461-2470`。
|
||||
- 32×32、r8、主色渐变的头像 CSS 存在,但患者模板已注释,不参与实际页面。证据:`index.vue:207,2447-2459`。
|
||||
- `NEW` 是 small (`20px` 高)、danger、dark tag:白字、danger 实色底;字体 12px、r4。证据:`index.vue:198`、Element Plus `tag.scss:66-87,142-165`、`common/var.scss:1110-1138`。
|
||||
|
||||
#### 挂号块
|
||||
|
||||
- 每个预约 badge:11px/600、padding `2px 6px`、r4、下 margin `2px`;医生 14px/600 `#333`;时间 12px、上 margin `2px`。证据:`index.vue:2496-2519`。
|
||||
- 活跃预约 badge 为 `#3A4ACC` / `rgba(74,93,255,.15)`,时间硬编码 `#4A5DFF`;整块为 135° 蓝色渐变 `.12 → .06`,边 `rgba(74,93,255,.3)`。证据:`index.vue:2522-2531,2556-2559`。
|
||||
- 已完成预约用 info-dark-2 / info-light-7;爽约用 warning-dark-2 / warning-light-7。整块也分别换成 info 或 warning 透明渐变。证据:`index.vue:2533-2569`。
|
||||
- 最近渠道为上 margin `6px`、12px、line-height `1.35`、`#999`;未挂号为 13px `#A8ABB2`。证据:`index.vue:2571-2581`。
|
||||
|
||||
#### 语义文字与 tag
|
||||
|
||||
- 已确认:13px/500 success;未确认:13px/600 warning。证据:`index.vue:2584-2594`。
|
||||
- 已开方:13px/500、硬编码 `#4A5DFF`;未开方:13px `#A8ABB2`。证据:`index.vue:2596-2605`。
|
||||
- 复诊时间 13px/500 `#333`,医生 12px `#999`,纵向 gap `4px`、line-height `1.35`;“处方已作废”是 small/plain/info tag。证据:`index.vue:268-285,2651-2676`。
|
||||
- 未服务天数:最小 `28px`、13px/600、等宽数字;空值灰 `#A8ABB2`/400,`<=2` 绿 `#16A34A`,`3-6` 橙 `#EA580C`,`>=7` 红 `#DC2626`。证据:`index.vue:305-316,1319-1329,2607-2632`。
|
||||
- 视频旁观为 12px、line-height `1.4`,左右 padding `2px`;不可进入时是 `#999`。证据:`index.vue:318-341,2690-2704`。
|
||||
|
||||
## 8. 按钮、dropdown、tooltip
|
||||
|
||||
### 8.1 操作列链接
|
||||
|
||||
直接动作依次为:查看(primary)、诊单(primary)、开方/查看处方(primary)、预约(success)、补全身份证(warning),最后是 info“更多”。权限会移除部分按钮。证据:`index.vue:345-403`。
|
||||
|
||||
- 均为 `size="small"`、`link`:12px,透明边/底,padding `2px`,高度 auto;hover 使用各语义色的 light-3。证据:Element Plus `button.scss:223-248`、`element.scss:113-127`。
|
||||
- `.action-cell` 设 flex-wrap + `gap:4px`;但相邻 `.el-button + .el-button` 仍有 `12px` 左 margin,因此连续按钮间通常是 **16px(4+12)**。dropdown 外壳与前一按钮之间只有 flex gap `4px`。证据:`index.vue:2683-2688`、Element Plus `button.scss:72-74`。
|
||||
- 340px 操作列不足时允许链接换行;没有强制单行或省略号。
|
||||
|
||||
### 8.2 “更多”菜单
|
||||
|
||||
- click 触发、`bottom-end` 对齐。菜单项按权限/状态可包含:指派、取消指派、视频二维码、二维码、取消挂号、挂号日志、创建订单、删除。证据:`index.vue:379-403`。
|
||||
- Element 默认 dropdown item 为 14px、line-height `22px`、padding `5px 16px`,故普通项约 `32px` 高;菜单上下净 padding 约 `5px`,r4、overlay 白底,popper 有 light border 与 shadow。hover 为 fill-light + primary。证据:Element Plus `dropdown.scss:7-24,42-70,137-176`。
|
||||
- 删除项有 divided 顶线,文字由 `.text-danger` 强制为 danger。证据:`index.vue:400,2706-2708`。
|
||||
- tooltip 使用 Element 默认 popper:12px、line-height `20px`、padding `5px 11px`;实际位置由视口碰撞计算,不能从源码固定。
|
||||
|
||||
## 9. 分页、空态与加载态
|
||||
|
||||
### 9.1 分页
|
||||
|
||||
- 列表默认 `page=1, size=15`;共享组件页容量选项为 `[15,20,30,40]`。证据:`src/hooks/usePaging.ts:14-34`、`components/pagination/index.vue:18-28`。
|
||||
- 布局固定为 `total, sizes, prev, pager, next, jumper`,最多显示 5 个 pager,单页也不隐藏。证据:`components/pagination/index.vue:3-14,24-28`。
|
||||
- 容器右对齐,padding `10px 16px 16px`。默认分页按钮 `32×32px`(最小宽),14px,r2,项组间 gap token `16px`;active 仅主色文字 + bold,并未启用 background 模式。证据:`index.vue:408-410,2436-2440`、Element Plus `common/var.scss:1034-1057`、`pagination.scss:5-67`。
|
||||
- page-size select 框架宽 `128px`,jumper 输入 `56px`。证据:Element Plus `pagination.scss:73-75,118-150`。
|
||||
- 视口 `<=768px` 时,全局 CSS 隐藏 size selector 与 jumper,仍保留 total、prev、pager、next。证据:`src/styles/element.scss:177-184`。
|
||||
|
||||
### 9.2 空态
|
||||
|
||||
页面没有提供 table `#empty` slot,也没有在主列表使用 `el-empty`。因此空列表为:
|
||||
|
||||
- sticky empty block,min-height `60px`;
|
||||
- 居中纯文本“暂无数据”;
|
||||
- 文字 `#999`,line-height `60px`,宽 50%;
|
||||
- 表头、底线和分页仍存在。
|
||||
|
||||
证据:`index.vue:179-190`、Element Plus `table.scss:52-70`、`src/App.vue:5,25-28`、Element 中文 locale `zh-cn.mjs:137`。
|
||||
|
||||
### 9.3 加载
|
||||
|
||||
- `v-loading="pager.loading"` 直接挂表格;普通请求开始时 loading=true,完成后 false。证据:`index.vue:179-184`、`src/hooks/usePaging.ts:35-72`。
|
||||
- 蒙层绝对覆盖 table,z-index 2000,亮色为 `rgba(255,255,255,.5)`;中央环 `42px`,主色描边 `2px`,2 秒旋转、1.5 秒 dash 动画。证据:`src/styles/var.css:41-43`、Element Plus `loading.scss:19-73,85-103`、`common/var.scss:1340-1349`。
|
||||
- 页面每 20 秒静默刷新列表和计数;标签页不可见时跳过,静默刷新不打开 loading,因此不会周期性白闪。证据:`index.vue:1098-1105,2182-2195`、`src/hooks/usePaging.ts:36-42,64-71`。
|
||||
- 首次 `pager.loading` 初值是 false,但 mount 后立即调用 `getLists()`,正常网络下会很快进入蒙层;能否肉眼看到取决于响应时长。
|
||||
|
||||
## 10. 响应式与滚动行为
|
||||
|
||||
1. `index.vue` 的 scoped 样式**没有 media query**。页面级响应式主要来自 flex-wrap、宽度上限、Element Table 横向滚动及全局分页规则。
|
||||
2. body 被全局设为 `min-width:375px`、`overflow:hidden`;应用主内容通过 `el-scrollbar` 承担页面滚动。证据:`src/styles/public.scss:1-3`、`layout/default/components/main.vue:2-11`。
|
||||
3. 第一筛选行本身不换行。左侧日期 chip 可换行,但右侧 160px 搜索 + 32px 查询按钮仍与左侧同一 flex 行;在极窄内容宽度下可能压缩、增高左侧或溢出,源码没有把搜索行堆到下一行的断点。
|
||||
4. 待分配容器可换行,宽搜索最多 `42vw`;展开筛选区可换行,两个日期范围各为 `260px` 且 `max-width:100%`。证据:`index.vue:2298-2317,2384-2405`。
|
||||
5. 表格 min 总宽约 1403px。Element Table 内部提供横向 scrollbar;右侧 460px fixed 区保持可见,导致很窄时可见的左侧业务列空间进一步减少。
|
||||
6. 外层应用在窄屏会折叠侧栏,但那是壳层行为,不改变本页筛选/表格 DOM。证据:`src/App.vue:36-53`。
|
||||
7. dark mode 有完整全局变量,但页面的硬编码蓝/绿/橙/红不会自适应;其亮度对比应另做暗色运行态验收。
|
||||
|
||||
## 11. 直接挂载的覆盖层(不占主页面静态布局)
|
||||
|
||||
主列表下方常驻挂载多个异步组件或 dialog/drawer;关闭时不占主页面空间,操作后覆盖在页面之上:
|
||||
|
||||
| 入口 | 覆盖层 | 关键尺寸 | 证据 |
|
||||
|---|---|---:|---|
|
||||
| 新增/诊单 | `edit.vue` 诊单 drawer | `60%` | `index.vue:414`、`edit.vue:1-10` |
|
||||
| 详情组件 | `detail.vue` popup | `800px` | `index.vue:415`、`detail.vue:1-10` |
|
||||
| 开方/处方 | `tcm-prescription` drawer | `1200px` | `index.vue:416`、`components/tcm-prescription/index.vue:1-8` |
|
||||
| 预约 | `appointment.vue` drawer | `60%` | `index.vue:417`、`appointment.vue:1-10` |
|
||||
| 视频旁观 | AssistantWatchCall dialog | `760px`;视频 grid min `220px` | `index.vue:418-422`、`AssistantWatchCallDialog.vue:1-19,242-284` |
|
||||
| 挂号日志 | drawer | `460px` | `index.vue:424-447` |
|
||||
| 小程序二维码 | dialog | `400px`;二维码 `256×256` | `index.vue:449-481` |
|
||||
| 指派医助 | dialog | `500px` | `index.vue:483-531` |
|
||||
| 创建订单 | dialog | `600px` | `index.vue:533-587` |
|
||||
| 补全身份证 | dialog | `400px` | `index.vue:589-615` |
|
||||
| 企微记录 | drawer | `600px` | `index.vue:617-722` |
|
||||
|
||||
这些覆盖层说明“操作按钮点击后”的页面层级,但其完整内部视觉不应混入诊单 index 主页面的静态复刻范围。
|
||||
|
||||
## 12. PySide 映射建议
|
||||
|
||||
### 12.1 页面与筛选
|
||||
|
||||
| Vue/CSS 意图 | PySide 建议 | 复刻要点 |
|
||||
|---|---|---|
|
||||
| 外层 `el-scrollbar` + 两张 card | `QScrollArea` → 单一 `QWidget/QVBoxLayout`;卡用 `QFrame` | viewport `#F6F6F6`;外 margin `16V/8H`;卡 r4、白底、无 border/shadow;卡间 12 |
|
||||
| date chips | 自定义 `FlowLayout` + checkable `QToolButton` | padding 6×12、r6、gap6;计数拼接为同一 label,保持源码顺序 |
|
||||
| 业务色 chip | 同一 button class 加 dynamic property | `pending_booking/info`、`completed/success`、`pending/warning`;active 白字实底 |
|
||||
| 第一行搜索 | `QHBoxLayout` 右侧固定 `QLineEdit(160)` + `QPushButton` | 高 32、gap8;若追求源码一致,不自行添加窄屏 stack |
|
||||
| 第二行筛选 | `QHBoxLayout`/FlowLayout + `QToolButton` | top line、上下 8;13px;active primary-light-9 |
|
||||
| 更多筛选 | 可显隐 `QWidget` + FlowLayout | 无动画;top dashed border;gap10;small 控件 24,日期范围 32 |
|
||||
|
||||
Qt 没有内建通用 FlowLayout,建议采用 Qt 示例式 `FlowLayout`,并把 chip 的 `sizeHint()` 包含字体宽度、左右 12/10px padding;不要用固定总宽,否则计数从一位变三位时会截断。
|
||||
|
||||
### 12.2 表格
|
||||
|
||||
- 使用 `QTableView + QAbstractTableModel`,不要堆 15 行 QWidget。列的 initial/min widths 按本规格表设置;表头高建议从 `39px` 起,常规 row 从 `52px` 起,并由 delegate `sizeHint()` 为多预约/操作换行增高。
|
||||
- 选择列用 check-state delegate;ID/患者、确认、复诊、开方、未服务天数分别用专用 delegate 绘文字和 tag,避免嵌套控件带来的滚动性能问题。
|
||||
- 挂号列用一个复合 delegate:r6 的状态背景、11px badge、14px/600 医生、12px 时间、多预约虚线分隔。可点击“取消挂号”若必须嵌入,可在命中区域处理 mouse event,而不是每格常驻 QPushButton。
|
||||
- 两个右固定列可用**同步滚动的双 `QTableView`**:主表隐藏最后两列,右表只显示 120/340 两列,共宽 460;共享 model、selection model、vertical scrollbar 与 row height。单一 QTableView 无 Element 的 sticky fixed-column 等价物。
|
||||
- 行背景应在 delegate 对每个 cell 绘制:普通白/`#FAFAFA`,hover `#F8F8F8`;业务行覆盖渐变并在第一可见 cell 绘 3px 语义条。不要把渐变只画在 viewport 底层,否则会重现 HTML `td` 遮挡问题。
|
||||
- 操作列优先使用 delegate + 命中区域或常驻 `QWidget` 行容器;若用按钮,链接样式为 12px、2px padding、无背景/边框,并预留源码实际的较大按钮间距。
|
||||
- 横向滚动时保持右表固定;表头与 body 必须共用列宽。多预约行要同步两张表的 row height。
|
||||
|
||||
### 12.3 dropdown、分页、空态、加载
|
||||
|
||||
- “更多”使用 `QMenu`,item 高约 32、左右 padding16、14px;按权限/状态动态构建,删除项前 `addSeparator()` 且 danger 文本。
|
||||
- 分页建议自定义 `PaginationBar`:total、15/20/30/40 combo、prev、最多 5 页、next、jump;高度32,容器右对齐并保留 `10T/16H/16B`。窗口内容宽度 <=768 时隐藏 combo 与 jump。
|
||||
- 空态在 `QTableView.viewport()` 中央放一个透明 `QLabel("暂无数据")`,有效高度 60;不要用大插画。仍显示 header 和分页。
|
||||
- 加载态用覆盖 table rect 的半透明 widget:亮色 `rgba(255,255,255,.5)`,中央约 42px 主色 spinner。新请求显示;20 秒静默刷新不显示。
|
||||
- 用 generation/request id 丢弃过期异步响应,并只在当前请求结束时关闭 overlay;这不是 Vue 当前 `latestOnly:false` 的视觉保证,但能避免 PySide 快速筛选时旧响应造成闪回。
|
||||
|
||||
### 12.4 主题与字体
|
||||
|
||||
- 定义集中 token,而不是把 `#4A5DFF` 散落在 QSS;但为了忠实,挂号块与“已开方”目前应保留硬编码蓝,或在设计确认后统一为 theme primary。
|
||||
- Windows 上首选“Microsoft YaHei UI”还是源码的“Microsoft YaHei”会改变字宽;逐像素复刻应显式用 `Microsoft YaHei`,14px CSS 大致映射为 Qt pixelSize 14,而不是 pointSize 14。
|
||||
- Qt 的 devicePixelRatio 与 CSS px 不同。截图验收需固定 Windows 缩放(建议 100%)、字体、窗口内容宽度和主题。
|
||||
|
||||
## 13. 能从源码确定 / 不能从源码确定
|
||||
|
||||
### 可以确定
|
||||
|
||||
- DOM 顺序、无页面内标题、两张卡、每个筛选和操作入口;
|
||||
- 页面局部 padding/gap/宽度、列声明宽度、固定列、颜色、字体大小/字重;
|
||||
- 日期 chip 的顺序与当天默认态;
|
||||
- 表格 stripe、业务类判定优先级、语义文本与预约块状态;
|
||||
- 共享分页的页容量、布局、空态文案、loading 结构;
|
||||
- 默认亮/暗 token 及运行时主题派生方式;
|
||||
- 权限或数据条件控制哪些按钮/tag 出现。
|
||||
|
||||
### 仅运行态可最终确定
|
||||
|
||||
1. **最终换行和行高**:取决于 viewport、数据长度、按钮权限、预约条数、字体回退、DPR;chip 和多数 row 都没有固定 height。
|
||||
2. **实际主题色**:setting 存在本地缓存,账号可改主色及语义色;dark 状态也来自浏览器存储/系统偏好。
|
||||
3. **业务行静态渐变是否完整可见**:受 `tr` 背景、striped/fixed `td` 背景与 Chromium table paint 顺序影响;源码只能确定规则叠加关系。
|
||||
4. **fixed-column 阴影和 scrollbar 尺寸**:由 Chromium/Element 的滚动状态、平台 scrollbar 策略决定。
|
||||
5. **tooltip/dropdown 的最终坐标**:popper 会做视口碰撞与翻转。
|
||||
6. **加载态停留时间和计数出现时间**:由 API 延迟、idle callback 调度决定;静默轮询通常无蒙层。
|
||||
7. **应用壳标题/面包屑/多页签**:由路由菜单、setting 和权限生成,不是 `index.vue` 的页面内视觉。
|
||||
|
||||
若后续要求“像素级截图一致”,应在固定 `Element Plus 2.13.7 + Chromium 版本 + 100% Windows 缩放 + 默认亮色主题 + 固定权限/样本数据` 下补一轮运行态测量;本文件已经给出源码能够保证的全部静态约束与明确边界。
|
||||
@@ -0,0 +1,153 @@
|
||||
# 诊单视觉重构最终业务回归审计(三次终验)
|
||||
|
||||
- 终验日期:2026-08-10
|
||||
- 范围:生产视频/IM、医生备注与日常记录 mutation、处方/订单、多挂号精确取消、诊单 Shell owner、列表 QR/日志/订单;并复核上一版已关闭的权限、DTO、隐私与 generation 项。
|
||||
- 基准:当前最新 Python 源码与测试,以及 `D:/web/zyt/admin`、`D:/web/zyt/server` 的实际 admin 行为和服务端控制器/Logic。
|
||||
- 约束:只读业务终验;未安装依赖、未打包、未访问线上接口。除更新本报告外,未修改业务源码或测试。
|
||||
|
||||
## 最终结论
|
||||
|
||||
第三次终验后的剩余项为:
|
||||
|
||||
- **P0:0,明确无 P0。**
|
||||
- **P1:1 项 OPEN。** 诊单列表“创建订单”已调用真实 `order.order/create`,但没有消费返回的 `order_no` 并调用现成的 `generate_order_qrcode`,admin 的付款二维码闭环仍中断。
|
||||
- **P2:2 项 OPEN。** 一是日常矩阵不能编辑已有血糖/饮食/运动记录;二是视频回放只显示每条记录的第一个 URL,且没有 admin 的“向具体通话记录追加回放”入口。
|
||||
- **服务端不可实现限制:不计入 P0/P1/P2。** 医生备注与跟踪备注的服务端契约只支持按当天追加;服务端没有任意旧正文覆盖或整条删除 endpoint,桌面端无法在不扩展后端的前提下实现这些动作。
|
||||
- 其余本轮复核项均 **CLOSED**:生产视频/IM 取数与同步、备注追加/附件、日常新增与待办、处方动作、订单详情/偏移、精确挂号取消、列表视频/确认 QR 与日志、真实 Shell 60%/move-resize/close generation。
|
||||
- 定向离线回归:**128/128 passed**。
|
||||
|
||||
| 复核项 | 三次终验状态 | 最终等级 | 摘要 |
|
||||
|---|---|---:|---|
|
||||
| 生产视频/IM `only_archived`、sync、上传、回放 | **OPEN(主体 CLOSED)** | **P2** | 生产接口、同步、工具栏上传和首个回放可用;多 URL 与指定 call-record 追加入口缺失 |
|
||||
| 医生备注 mutation | **CLOSED** | — | 追加正文、上传舌象/报告、删除单附件均走真实合同;正文覆盖/整条删除属后端无 endpoint 限制 |
|
||||
| 日常记录 mutation | **OPEN(新增 CLOSED)** | **P2** | 新增血糖/饮食/运动/跟踪备注及待办新增/取消可用;已有三类记录不能编辑 |
|
||||
| 处方/详情订单动作 | **CLOSED** | — | 开方、处方查看、业务订单列表/详情与诊次偏移均真实落库/取数 |
|
||||
| 多挂号精确取消 | **CLOSED** | — | 每个挂号卡携带精确 ID,最终调用 `doctor.appointment/cancel` |
|
||||
| 诊单 owner、60%、move-resize、generation | **CLOSED** | — | 每次打开重绑真实 Shell,宽度为 Shell 60%,移动/缩放/关闭同步且所有异步代次失效 |
|
||||
| 列表 QR / 日志 / 创建订单 | **OPEN(QR/日志/创建主体 CLOSED)** | **P1** | 视频/确认 QR、日志、创建订单均真实调用;创建成功后的支付 QR 未接入 UI |
|
||||
|
||||
上一版已关闭的 `phonePlain`、canonical `diagnosis_type`、`viewOnly` endpoint 分流、detail 失败禁存、身份证 15/18 位校验、权限 Tab 裁剪与隐私脱敏,本轮定向回归均未发现反弹。
|
||||
|
||||
## 逐项证据
|
||||
|
||||
### 1. 生产视频/IM:`[OPEN / P2,主体 CLOSED]`
|
||||
|
||||
已关闭部分:
|
||||
|
||||
- `RemoteDoctorRepository` 已实现生产 `getCallRecords`、手工 call record、附件关联及“上传 → 创建记录 → 关联”顺序:`src/doctor_workstation/services/repository.py:1875-1943`。这与 admin `CallRecordPanel` 的真实流程一致:`D:/web/zyt/admin/src/views/tcm/diagnosis/components/CallRecordPanel.vue:109-180`。
|
||||
- IM 首次读取明确发 `only_archived=1`,同步走 `triggerImChatSync`:`src/doctor_workstation/services/repository.py:1945-1965`;dialog 的 lazy 查询也固定 `only_archived=True`:`src/doctor_workstation/ui/dialogs/diagnosis.py:2016-2038`。admin 对照为 `D:/web/zyt/admin/src/views/tcm/diagnosis/components/ImChatRecordPanel.vue:148-185`。
|
||||
- edit 态视频工具栏调用真实上传,成功后按 media generation 重新加载;聊天同步成功后延时重新读取归档:`src/doctor_workstation/ui/dialogs/diagnosis.py:2541-2611`。聊天图片/文件只能通过 HTTP(S) 安全打开:`:2652-2658`。
|
||||
- production/server 合同存在:`D:/web/zyt/server/app/adminapi/controller/tcm/DiagnosisController.php:397-438`、`:505-538`;归档快路径在 `D:/web/zyt/server/app/adminapi/logic/tcm/DiagnosisLogic.php:1015-1045`。
|
||||
|
||||
仍 OPEN 的 P2:
|
||||
|
||||
- 服务端返回 `recording_urls_list` 数组:`D:/web/zyt/server/app/adminapi/logic/tcm/DiagnosisLogic.php:1810-1833`;admin 把完整数组交给播放器,并在每个具体 call row 提供上传入口:`D:/web/zyt/admin/src/views/tcm/diagnosis/components/CallRecordPanel.vue:30-72`。
|
||||
- Python `_fill_video` 只取 `raw_urls[0]`,其余回放不可见,且视频表没有指定 `call_record_id` 的上传操作列:`src/doctor_workstation/ui/dialogs/diagnosis.py:1126-1135`、`:2844-2881`。虽然 repository 已支持 `call_record_id`(`src/doctor_workstation/services/repository.py:1894-1913`),UI 没有把它接出来。
|
||||
|
||||
该问题不造成错误写入,且工具栏上传与一个回放可用,故按 **P2** 而非 P1 计。
|
||||
|
||||
### 2. 医生备注:`[CLOSED;后端限制已明确]`
|
||||
|
||||
- NotesTimeline 已暴露新增、上传、删除单附件与打开附件信号:`src/doctor_workstation/ui/diagnosis_drawer.py:1804-1810`、`:1820-1851`、`:1887-1961`;dialog 绑定真实方法:`src/doctor_workstation/ui/dialogs/diagnosis.py:989-997`、`:2376-2487`。
|
||||
- production DTO 分别走 `doctor.appointment/addDoctorNote`、`doctor.appointment/doctorNotes`、`doctor.appointment/deleteDoctorNoteImage`:`src/doctor_workstation/services/repository.py:935-985`。权限分别按新增与删除附件 endpoint 裁剪:`src/doctor_workstation/ui/dialogs/diagnosis.py:478-483`、`:1233-1241`。
|
||||
- admin 自身也只有追加、上传、删除单附件动作:`D:/web/zyt/admin/src/views/patient/reception/components/NoteTimeline.vue:195-268`;API 定义仅三条:`D:/web/zyt/admin/src/api/patient.ts:19-40`。
|
||||
|
||||
服务端不可实现边界:
|
||||
|
||||
- controller 仅暴露 `addDoctorNote`、`doctorNotes`、`deleteDoctorNoteImage`,随后类即结束:`D:/web/zyt/server/app/adminapi/controller/doctor/AppointmentController.php:168-198`。
|
||||
- `DoctorNoteLogic::addOrAppend` 对当天正文使用换行追加,不支持任意旧正文覆盖:`D:/web/zyt/server/app/adminapi/logic/doctor/DoctorNoteLogic.php:12-76`;唯一删除逻辑只删除附件数组中的单项:`:111-136`。
|
||||
|
||||
因此“任意旧笔记正文覆盖/整条删除”不是桌面端漏接已有合同,而是服务端没有 endpoint;本报告将其列为**后端限制,不计客户端 P0/P1/P2**。
|
||||
|
||||
### 3. 日常记录:`[OPEN / P2,新增 CLOSED]`
|
||||
|
||||
已关闭部分:
|
||||
|
||||
- edit 态已提供血糖、饮食、运动、跟踪备注新增按钮和待办新增/取消;只读态/无权限态隐藏:`src/doctor_workstation/ui/diagnosis_drawer.py:1363-1371`、`:1431-1450`、`:1481-1511`、`:1760-1791`。
|
||||
- dialog 将 signal 接到真实 add/todo repository,并为每次 mutation 使用 diagnosis-id + generation 校验后强制刷新:`src/doctor_workstation/ui/dialogs/diagnosis.py:1089-1101`、`:2175-2358`。
|
||||
- production 合同为 `tcm.bloodRecord/add`、`tcm.dietRecord/add`、`tcm.exerciseRecord/add`、`tcm.diagnosis/addTrackingNote`、todo add/cancel:`src/doctor_workstation/services/repository.py:1745-1765`、`:1778-1805`、`:1847-1873`。
|
||||
|
||||
仍 OPEN 的 P2:
|
||||
|
||||
- admin 点击已有单元格时会把记录(含 `id`)放入表单,保存时分别调用 blood/diet/exercise edit:`D:/web/zyt/admin/src/views/tcm/diagnosis/components/DailyMatrix.vue:921-1013`。
|
||||
- Python matrix 明确 `NoEditTriggers`、`NoSelection`,只有 `daily_add_requested`,没有已有记录 edit signal:`src/doctor_workstation/ui/diagnosis_drawer.py:1366-1371`、`:1455-1463`。dialog 也只选择 `add_*`:`src/doctor_workstation/ui/dialogs/diagnosis.py:2260-2284`。
|
||||
- 这不是后端限制:production repository 已实现三类 edit:`src/doctor_workstation/services/repository.py:1767-1776`、`:1787-1796`、`:1807-1818`。
|
||||
|
||||
跟踪备注例外:server 设计就是“仅给今天追加”。controller 仅有 add/list:`D:/web/zyt/server/app/adminapi/controller/tcm/DiagnosisController.php:180-214`;`TrackingNoteLogic` 只有 `addOrAppend/getByDiagnosis`,类在第 95 行结束:`D:/web/zyt/server/app/adminapi/logic/tcm/TrackingNoteLogic.php:8-95`。其任意旧正文覆盖/整条删除同样属于后端无 endpoint 限制,不计客户端问题。
|
||||
|
||||
### 4. 处方与详情订单动作:`[CLOSED]`
|
||||
|
||||
- “开具处方”打开真实 `PrescriptionEditorDialog`,accepted 后调用 `create_prescription`,并携带 diagnosis/appointment/case snapshot:`src/doctor_workstation/ui/dialogs/diagnosis.py:2489-2539`。
|
||||
- 历史处方的“查看”打开真实 `PrescriptionDetailDialog`:`:2791-2842`。
|
||||
- 业务订单 lazy 列表、详情权限/真实 repository 门禁、异步 order generation 及详情 dialog 均已接通:`:1998-2000`、`:2069-2074`、`:2883-2991`。
|
||||
- 全局诊次偏移调用真实 `setRevisitSlotStartOffset`,并有独立 generation:`:2613-2650`。
|
||||
|
||||
未发现处方或详情订单的假动作/越权入口,本项 **CLOSED**。
|
||||
|
||||
### 5. 多挂号精确取消:`[CLOSED]`
|
||||
|
||||
- 每个可取消挂号卡按精确 primary key 生成按钮,signal 携带原 record 与该 appointment-id:`src/doctor_workstation/ui/diagnosis_index_widgets.py:150-163`、`:1121-1148`。歧义的行级“取消挂号”仍只在唯一挂号时出现:`:1403-1410`。
|
||||
- handler 选择当前诊单后再次按 ID 和状态校验,确认前后都防 stale,最终仅调用 exact capability:`src/doctor_workstation/ui/pages/consultations.py:1868-1921`。
|
||||
- production 方法明确走 `doctor.appointment/cancel`,不再误用患者工作区 endpoint:`src/doctor_workstation/services/repository.py:1545-1555`。
|
||||
- 新测试覆盖两个挂号、超 32 位 appointment-id、错误诊单、状态刷新、权限/能力缺失与 canonical endpoint:`tests/test_diagnosis_index_visual.py:340-379`、`tests/test_consultations_parity_ui.py:610-774`、`tests/test_diagnosis_detail_contract.py:90-105`。
|
||||
|
||||
本项 **CLOSED**。
|
||||
|
||||
### 6. 真实 Shell owner / 60% / move-resize / generation:`[CLOSED]`
|
||||
|
||||
- `_owner` 不再在 page 构造期缓存;每次 `open_for/showEvent` 都从当前 `parent.window()` 重绑真实 Shell:`src/doctor_workstation/ui/dialogs/diagnosis.py:526-527`、`:1314-1349`、`:1398-1408`。
|
||||
- overlay 与 owner 同位置/尺寸,宽屏 drawer 固定为 owner 宽度 60%;监听 owner 的 Resize/Move/Show/Close:`:1283-1299`、`:1351-1371`。
|
||||
- `reject/done` 及 owner Close 会使 detail/save/orders/order-detail/daily/notes/media/offset/所有 lazy-tab generation 全部失效:`:1373-1383`、`:1681-1700`。
|
||||
|
||||
真实 `ShellWindow -> QStackedWidget -> ConsultationsPage -> cached DiagnosisDialog` offscreen 探针结果:
|
||||
|
||||
```text
|
||||
OPEN owner_shell=True shell=(1200,760) dialog=(1200,760) panel=720 expected=720 pos=(80,60)
|
||||
MOVED shell=(1024,650) dialog=(1024,650) panel=614 expected=614 pos=(190,140)
|
||||
CLOSE visible=False generation=True media=True
|
||||
```
|
||||
|
||||
本项 **CLOSED**。现有 `tests/test_diagnosis_drawer_visual.py:837-861` 仍只用直接顶层 owner;本次额外真实 Shell 探针补足了业务终验,但建议后续把该探针固化成回归测试。
|
||||
|
||||
### 7. 列表 QR / 日志 / 订单:`[OPEN / P1,主体 CLOSED]`
|
||||
|
||||
已关闭部分:
|
||||
|
||||
- 页面只在 canonical 权限与真实 repository 方法同时存在时展示视频 QR、确认 QR、日志、订单入口:`src/doctor_workstation/ui/pages/consultations.py:704-715`、`:1005-1030`。
|
||||
- QR DTO 校验 patient/doctor/share-user/diagnosis,日志使用 diagnosis-id,所有菜单请求均受 selection generation 保护:`:1972-2091`。
|
||||
- production repository 真实调用 `generateMiniProgramQrcode`、`guahaoLogList`、`order.order/create`:`src/doctor_workstation/services/repository.py:1967-2060`。测试验证 exact DTO、权限裁剪与 stale callback:`tests/test_diagnosis_detail_contract.py:85-127`、`tests/test_consultations_parity_ui.py:777-958`。
|
||||
|
||||
仍 OPEN 的 P1:
|
||||
|
||||
- admin 创建订单成功后读取 `order_no`,立即调用 `generateOrderQrcode` 并展示付款 QR:`D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:1474-1509`。
|
||||
- Python `_create_diagnosis_order` 把真实 create 放进通用 `_run_mutation`;该 helper 丢弃 `_result`,只 toast + refresh,因此永远不会生成付款 QR:`src/doctor_workstation/ui/pages/consultations.py:2093-2128`、`:2460-2479`。
|
||||
- repository 明明已有 `generate_order_qrcode`:`src/doctor_workstation/services/repository.py:2062-2076`,但 UI 源码没有任何调用。现有测试也只分别测试“UI 创建订单”和“repository 可生成 QR”,未覆盖两者串联:`tests/test_consultations_parity_ui.py:777-872`、`tests/test_diagnosis_detail_contract.py:88-89`。
|
||||
|
||||
订单已实际创建却不展示付款入口,用户容易停在半完成状态或重复建单;这是主流程闭环缺失,按 **P1 OPEN** 计。
|
||||
|
||||
## 离线验证记录
|
||||
|
||||
使用项目现有 `.venv`,设置 `QT_QPA_PLATFORM=offscreen`、`PYTHONDONTWRITEBYTECODE=1`,禁用 pytest cache,并把 basetemp 放在系统临时目录;未安装任何依赖:
|
||||
|
||||
```text
|
||||
pytest -q -p no:cacheprovider \
|
||||
tests/test_diagnosis_detail_contract.py \
|
||||
tests/test_diagnosis_index_visual.py \
|
||||
tests/test_diagnosis_drawer_visual.py \
|
||||
tests/test_appointment_drawer_visual.py \
|
||||
tests/test_consultations_parity_ui.py \
|
||||
tests/test_prescription_security_ui.py \
|
||||
tests/test_patients_ui.py \
|
||||
tests/test_repository_parity.py \
|
||||
tests/test_permissions.py
|
||||
|
||||
128 passed
|
||||
```
|
||||
|
||||
测试退出码为 0;`--collect-only -q` 分文件计数合计 128。另执行了上述真实 Shell owner 探针,退出码为 0。
|
||||
|
||||
## 发布判断
|
||||
|
||||
当前不存在错误 endpoint、歧义挂号取消、权限泄漏、隐私明文回流或关闭后旧异步回调落地等 P0 风险。生产视频/IM、备注追加、日常新增、处方、订单详情及列表主要动作均已从“假动作/未接入”进展为真实 repository 行为。
|
||||
|
||||
但若发布标准是“完整保留 admin 的订单支付闭环与所有已存在的数据编辑/回放能力”,当前仍不满足:**剩余 P0 0 / P1 1 / P2 2**。其中任意旧医生/跟踪笔记正文覆盖或整条删除必须先扩展 server endpoint,不能作为桌面端单独修复项。
|
||||
@@ -0,0 +1,113 @@
|
||||
# 诊疗工作台第三次、最终只读视觉同型审计
|
||||
|
||||
审计日期:2026-08-10
|
||||
审计性质:最终只读终验;除更新本报告外,未修改任何业务代码、测试、renderer 或 PNG。
|
||||
事实源:当前 Python 源码、D:/web/zyt/admin/src/views/tcm/diagnosis/ 下 Vue 基准、三份既有视觉规格,以及 artifacts/diagnosis_visual/ 当前 43 张 PNG。
|
||||
判定方法:43 张 PNG 均按原始尺寸逐张目检;源码只用于解释可见结果与确认真实能力。测试通过数不作为任何视觉结论的依据,本轮也未以测试替代目检。
|
||||
|
||||
## 最终结论
|
||||
|
||||
**PARTIAL,不是 PASS。**
|
||||
|
||||
- P0:**0 OPEN**。语义表单、1024 栅格、右固定列、双尺寸 60% 抽屉、固定 footer、核心 loading/empty/error/focus/permission 状态均未回退。
|
||||
- 上一版 12 项 P1:**8 项 CLOSED / EXACT,4 项仍 OPEN / PARTIAL**。
|
||||
- 43 张当前 PNG:**36 张 EXACT(其中两张 edit 仅指入镜区域)、7 张 PARTIAL、0 张乱码或不可读**。
|
||||
- 列表、详情、预约与 Shell 的所有 loading 图均为真实圆弧 spinner;没有字符型 spinner、方框箭头、中文乱码或缺字。
|
||||
- 当前不能判 PASS 的原因是:1024 Daily 动作行裁切及按钮语义态被压平;完整菜单仍缺 Vue 图标、danger 删除色和精确文案;备注附件视觉仍未命中预期样式;处方/订单/视频/聊天仍有可见简化或缺少关键截图证据。
|
||||
- “既有笔记整条正文覆盖 / 删除整条笔记”另列为服务端合同限制,不计作伪造桌面能力,也不用于掩盖上述视觉 P1。
|
||||
|
||||
## 上一版 12 项 OPEN 逐项终验
|
||||
|
||||
| # | 上一版问题 | 状态 | 同型分类 | 最新精确证据与目检结论 |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | 统计端就诊卡仍非 switch | **CLOSED** | **EXACT(组件)** | 当前已使用自绘二态 DiagnosisSwitch,44×24 轨道、圆形滑块、0/1 文本合同:src/doctor_workstation/ui/diagnosis_drawer.py:643-675;表单 show_card 分支:src/doctor_workstation/ui/dialogs/diagnosis.py:924-925;Vue 基准:D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:341-348。现有 edit PNG 没有滚到该字段,因此本项是源码级确定结论,不引用测试,也不声称有单独的 switch 截图。 |
|
||||
| 2 | Daily 编辑动作不完整 | **OPEN** | **PARTIAL** | + 血糖 / + 饮食 / + 运动 / + 备注、刷新、新增待办和精确可取消动作均已接入:src/doctor_workstation/ui/diagnosis_drawer.py:1431-1450、:1486-1512、:1764-1784;真实处理:src/doctor_workstation/ui/dialogs/diagnosis.py:2175-2356。可是 diagnosis_state_daily_1024x640.png 中最右“刷新”被抽屉右缘裁掉;原因是单个 QHBoxLayout 不换行(diagnosis_drawer.py:1398-1451),而 Vue 明确 flex-wrap(D:/web/zyt/admin/src/views/tcm/diagnosis/components/DailyMatrix.vue:1094-1109)。同时“最近7天”的 checked 态、+血糖 与新增待办的 primary 态均被 scoped 基础按钮规则压平;Vue 的主动作在 DailyMatrix.vue:25-35、DiagnosisTodoList.vue:3-19。 |
|
||||
| 3 | “更多”菜单动作被关闭 | **OPEN** | **PARTIAL** | 能力缺口已补齐:权限/真实方法策略在 src/doctor_workstation/ui/pages/consultations.py:1002-1031,handler 在 :1672-1692;diagnosis_full_menu_1280x800.png 已出现指派、取消指派、视频二维码、确认二维码、取消挂号、挂号日志、创建订单、删除。仍不同型:Python 只创建纯文字 QAction(src/doctor_workstation/ui/diagnosis_index_widgets.py:1381-1431),Vue 每项有图标,删除为 divided + danger 红,并使用“二维码”而不是“确认二维码”(D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:379-400)。QMenu 白底、边框、hover 与分隔线样式已生效(diagnosis_index_widgets.py:1371-1380),但未覆盖这些剩余语义。 |
|
||||
| 4 | Tab 溢出仍用左右箭头 | **CLOSED** | **EXACT** | 原生左右工具按钮宽度归零,额外 4 px 横向滚动条可见并驱动选中 Tab:src/doctor_workstation/ui/diagnosis_drawer.py:194-242、:749-792;Vue 基准:D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:1798-1828。所有 1024 详情状态图均无盒状左右箭头,蓝色细滚动条连续可见。 |
|
||||
| 5 | footer 缺上投影 | **CLOSED** | **EXACT** | footer 使用 blur 18、offset 0/-4、半透明深色 QGraphicsDropShadowEffect:src/doctor_workstation/ui/dialogs/diagnosis.py:784-803;Vue 为 0 -4px 24px / 7%:D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:1792-1796。edit/viewOnly/daily/save 状态图均可见 footer 上缘阴影,且 footer 始终在滚动体外。 |
|
||||
| 6 | 保存中无按钮 spinner / 结果态 | **CLOSED** | **EXACT** | SaveStateButton 以 QTimer + QPainter 画 loading 圆弧、成功勾和失败叉:src/doctor_workstation/ui/diagnosis_drawer.py:678-746;只在 editable/basic 显示:src/doctor_workstation/ui/dialogs/diagnosis.py:1652-1661;真实保存状态切换::3044-3163。diagnosis_state_save_loading/success/failure_1024x640.png 分别完整显示“正在保存 / 保存成功 / 保存失败”,无缺字。 |
|
||||
| 7 | readonly 摘要拆“客服” | **CLOSED** | **EXACT** | 摘要值禁止换行并使用 MinimumExpanding:src/doctor_workstation/ui/dialogs/diagnosis.py:681-712;“客服:…”赋值::1767-1770。diagnosis_readonly_1024x640.png 与 diagnosis_readonly_1440x900.png 均完整显示“客服:赵医助”,不再拆字。 |
|
||||
| 8 | Notes 缺新增、上传、删除动作 | **OPEN** | **PARTIAL** | 动作和真实端点已存在:NotesTimeline 信号/工具栏/单附件删除在 src/doctor_workstation/ui/diagnosis_drawer.py:1804-1962;处理在 src/doctor_workstation/ui/dialogs/diagnosis.py:2376-2478;远端合同在 src/doctor_workstation/services/repository.py:935-985。diagnosis_state_notes_actions_1024x640.png 已显示新增/追加、上传舌象、上传报告、打开与单附件删除。但视觉仍不同型:QSS 写的是 QLabel#DiagnosisTongueThumb 与 QLabel#DiagnosisAttachmentChip(diagnosis_drawer.py:486-500),运行时却创建 QPushButton(:1897-1900、:1932-1937),所以截图中的“舌象/查看”和报告附件退化为普通白按钮,而非 Vue 的真实缩略图、文件 chip 与叠加 CircleClose(D:/web/zyt/admin/src/views/patient/reception/components/NoteTimeline.vue:42-100)。 |
|
||||
| 9 | 处方、订单、视频、聊天仍为简化实现 | **OPEN** | **PARTIAL / MISSING** | 处方已接完整 PrescriptionEditorDialog 和真实 create_prescription(src/doctor_workstation/ui/dialogs/diagnosis.py:2489-2539),订单偏移及详情入口已接真实方法(:2613-2650、:2883-2941),视频上传/回放和聊天归档同步也已接真实方法(:2541-2611、:2844-2881)。但视觉仍未闭合:处方按钮在 diagnosis_state_empty_1024x640.png 中为普通白色“开具处方”,而 Vue 是 primary 小号“开方”(D:/web/zyt/admin/src/views/tcm/diagnosis/components/CaseRecordList.vue:1-5);当前证据集没有处方编辑器或订单详情抽屉 PNG。diagnosis_state_video_replay_1024x640.png 只有外链“播放回放”,代码通过 QDesktopServices 打开 HTTP(S)(diagnosis.py:2652-2658、:2874-2881),而 Vue 是表格内 RecordingPlaybackBlock(D:/web/zyt/admin/src/views/tcm/diagnosis/components/CallRecordPanel.vue:17-34)。diagnosis_state_chat_archive_1024x640.png 有气泡、文件和同步/重载,但省略 Vue 的完整 info alert 和详细归档说明(D:/web/zyt/admin/src/views/tcm/diagnosis/components/ImChatRecordPanel.vue:1-20)。订单偏移可见部分本身为 EXACT。 |
|
||||
| 10 | 表格状态仍整格着色而非 pill | **CLOSED** | **EXACT** | set_status_tag 以 QLabel pill 放入 cell widget,不再染整格:src/doctor_workstation/ui/diagnosis_drawer.py:2366-2384;所有状态列经 semanticize 转换:src/doctor_workstation/ui/dialogs/diagnosis.py:1900-1921、:2752-2754、:2822-2825、:2870-2874、:2898-2902。diagnosis_state_daily_lower_1024x640.png 的“待执行”蓝色 pill 是直接视觉证据。 |
|
||||
| 11 | Shell 仍为 216/68 且无 40 px tabs | **CLOSED** | **EXACT** | 侧栏 183:src/doctor_workstation/ui/shell.py:537-540;topbar 50::599-603;multiple-tabs 40::683-687。diagnosis_shell_1024x640.png 与 diagnosis_shell_1440x900.png 均可直接量出 183/50/40 三段,比例一致。 |
|
||||
| 12 | Shell 颜色偏离且箭头缺字 | **CLOSED** | **EXACT(本项范围)** | 侧栏为 #1D2124:src/doctor_workstation/ui/shell.py:541-569;方向、刷新、全屏、下拉、关闭图形均由 QPainter 自绘,不依赖字体 glyph::257-395;topbar/tab QMenu 样式::599-628、:683-723。两张最新 Shell PNG 中“更多筛选”、行“更多”、用户菜单和 tabs 菜单箭头均正常,没有方框或乱码。 |
|
||||
|
||||
## 最后增加的三个专项检查
|
||||
|
||||
| 专项 | 状态 | 结论 |
|
||||
|---|---|---|
|
||||
| scoped panel 按钮 | **OPEN / PARTIAL** | src/doctor_workstation/ui/diagnosis_drawer.py:112-136 的 QDialog#DiagnosisDialogRoot QPushButton 规则确实把详情按钮从全局暖灰绿主题隔离出来;但它的 ID 选择器优先级高于全局 QPushButton[variant="primary/ghost"](src/doctor_workstation/ui/theme.py:87-118),而局部 QSS 没有补 primary、ghost、dangerGhost、diagnosisRange:checked 规则。结果在 daily/处方/视频/待办 PNG 中所有动作都被压成同一白色按钮,选中态也不清楚。这是实图结果,不是测试推断。 |
|
||||
| QMenu | **OPEN / PARTIAL** | diagnosis_full_menu_1280x800.png 证明局部白底、细边框、32 px 左右行高、分隔线和菜单宽度已生效;但与 Vue 相比仍缺每项图标、danger 删除色,且“确认二维码”文案不精确。证据:src/doctor_workstation/ui/diagnosis_index_widgets.py:1371-1431 对照 D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:379-400、:2706-2708。 |
|
||||
| 紧凑 close | **CLOSED / EXACT** | Header close 被限定 32×32、透明常态、蓝色 hover/focus:src/doctor_workstation/ui/diagnosis_drawer.py:137-154;创建位置:src/doctor_workstation/ui/dialogs/diagnosis.py:754-782。diagnosis_state_focus_1024x640.png 显示紧凑透明常态,默认 edit/viewOnly 图显示蓝色 focus/hover 外观,没有被全局 40 px footer 按钮规则放大。 |
|
||||
|
||||
## 43 张 PNG 原尺寸目检矩阵
|
||||
|
||||
下表的 EXACT 只针对该张 PNG 实际入镜内容;不以不可见的滚动下方内容作证明。
|
||||
|
||||
| PNG | 分类 | 原尺寸目检结论 |
|
||||
|---|---|---|
|
||||
| diagnosis_1024x640.png | **EXACT** | 窄屏筛选换行、表格内部横向溢出、右固定列和阴影稳定,无硬裁切。 |
|
||||
| diagnosis_1440x900.png | **EXACT** | 宽屏列密度、复合挂号卡、斑马/业务语义色和固定操作区成立。 |
|
||||
| diagnosis_advanced_filters_1280x800.png | **EXACT** | 高级筛选即时展开,日期范围、控件顺序和重置入口完整。 |
|
||||
| diagnosis_double_appointment_cancel_1280x800.png | **EXACT** | 同一诊单两条预约均有各自取消入口,虚线层级和固定列正常。 |
|
||||
| diagnosis_empty_1280x800.png | **EXACT** | 表头、内部空态、横向滚动和 0 条分页均保留。 |
|
||||
| diagnosis_error_1280x800.png | **EXACT** | 错误文本居中可读,无旧数据穿透。 |
|
||||
| diagnosis_focus_1280x800.png | **EXACT** | 搜索框蓝色 focus ring 清楚且不挤压布局。 |
|
||||
| diagnosis_full_menu_1280x800.png | **PARTIAL** | 8 项动作与分隔线齐全;缺 Vue 图标、danger 删除色,二维码文案不同。 |
|
||||
| diagnosis_horizontal_scroll_1024x640.png | **EXACT** | 主表横移、右 460 px 固定区不动,左缘阴影连续。 |
|
||||
| diagnosis_hover_warning_1280x800.png | **EXACT** | warning 行 hover 渐变、3 px 左条和固定区同步。 |
|
||||
| diagnosis_loading_1280x800.png | **EXACT** | 表内真实圆弧 spinner,内容淡化,无字符缺字。 |
|
||||
| diagnosis_pending_assign_1280x800.png | **EXACT** | 待分配 chip、月份与宽搜索保持同一流式区。 |
|
||||
| diagnosis_permissions_cropped_1280x800.png | **EXACT** | 权限裁掉 toolbar 与敏感动作,仅保留查看。 |
|
||||
| diagnosis_edit_1024x640.png | **EXACT(可见区)** | 60% 抽屉、2/3 列语义表单、细 tab 条、固定 footer 无裁切;switch 未入镜。 |
|
||||
| diagnosis_edit_1440x900.png | **EXACT(可见区)** | 宽屏密度、字段比例和 footer 稳定;switch 未入镜。 |
|
||||
| diagnosis_readonly_1024x640.png | **EXACT** | 独立只读页、hero、病例卡完整,“客服”不拆字。 |
|
||||
| diagnosis_readonly_1440x900.png | **EXACT** | 4 列病例密度和异常指标层级完整,无不必要换行。 |
|
||||
| diagnosis_viewonly_1024x640.png | **EXACT** | 60% 只读抽屉、权限提示、禁用表单与仅关闭 footer 成立。 |
|
||||
| diagnosis_viewonly_1440x900.png | **EXACT** | 宽屏只读栅格、全 Tab 条和固定 footer 成立。 |
|
||||
| diagnosis_state_daily_1024x640.png | **PARTIAL** | DailyMatrix、异常/自录内容和新增动作出现;最右“刷新”被裁,range/primary 语义态被压平。 |
|
||||
| diagnosis_state_daily_lower_1024x640.png | **PARTIAL** | 趋势、待办筛选、表格与 pill 出现;“新增待办”不是 Vue primary,toolbar 语义层级不足。 |
|
||||
| diagnosis_state_empty_1024x640.png | **PARTIAL** | 处方空态和仅取消 footer 正确;“开具处方”按钮文案/primary 样式不同,且未覆盖真实处方编辑器。 |
|
||||
| diagnosis_state_error_1024x640.png | **EXACT** | danger banner、重试、空表单、禁用保存完整。 |
|
||||
| diagnosis_state_focus_1024x640.png | **EXACT** | 姓名 focus ring、紧凑 close 常态和固定 footer 清晰。 |
|
||||
| diagnosis_state_loading_1024x640.png | **EXACT** | 圆弧 spinner 与“正在加载诊单...”完整,无旧 seed 或缺字。 |
|
||||
| diagnosis_state_permission_1024x640.png | **EXACT** | 只保留病历/医生备注,手机号与身份证掩码,footer 仅关闭。 |
|
||||
| diagnosis_state_notes_actions_1024x640.png | **PARTIAL** | 增加/上传/打开/单附件删除均出现;舌象与报告未呈现为 Vue 缩略图/chip。 |
|
||||
| diagnosis_state_order_offset_1024x640.png | **EXACT(可见区)** | 偏移数值框、保存、订单表、分页与横向滚动完整;订单详情未入镜。 |
|
||||
| diagnosis_state_save_failure_1024x640.png | **EXACT** | 红色叉与“保存失败”完整。 |
|
||||
| diagnosis_state_save_loading_1024x640.png | **EXACT** | 白色圆弧 spinner 与“正在保存”完整。 |
|
||||
| diagnosis_state_save_success_1024x640.png | **EXACT** | 绿色勾与“保存成功”完整。 |
|
||||
| diagnosis_state_video_replay_1024x640.png | **PARTIAL** | 回放动作可见且文案完整;仍是外链按钮,不是 Vue 表内播放器。 |
|
||||
| diagnosis_state_chat_archive_1024x640.png | **PARTIAL** | 左右气泡、图片/文件入口、同步/重载可见;完整 info alert 与原文案缺失。 |
|
||||
| diagnosis_shell_1024x640.png | **EXACT(本项范围)** | 183/50/40,深色侧栏、分页、三角箭头和中文均无缺字。 |
|
||||
| diagnosis_shell_1440x900.png | **EXACT(本项范围)** | 同一 Shell 比例在宽屏保持,固定列阴影和 horizontal scroll 清楚。 |
|
||||
| appointment_drawer_1024x640.png | **EXACT** | 60% 右抽屉、日期/时段、body 滚动与固定 footer 无裁切。 |
|
||||
| appointment_drawer_1440x900.png | **EXACT** | 医生 radio、四个 130×40 日期按钮、时段网格、备注与 footer 对齐。 |
|
||||
| appointment_drawer_empty_doctors_1440x900.png | **EXACT** | warning、80 px 空态插图、“暂无可预约医生”和禁用确认完整。 |
|
||||
| appointment_drawer_empty_roster_1440x900.png | **EXACT** | 无排班 warning、空态插图和“该医生暂无排班”可读。 |
|
||||
| appointment_drawer_error_1440x900.png | **EXACT** | “号源加载失败:号源服务暂时不可用”、刷新入口与空态并存。 |
|
||||
| appointment_drawer_keyboard_focus_1440x900.png | **EXACT** | 130×40 日期按钮的蓝色键盘 focus 边界清楚。 |
|
||||
| appointment_drawer_loading_1440x900.png | **EXACT** | “正在加载医生、渠道与挂号状态...”与真实圆弧 spinner 完整,无缺字。 |
|
||||
| appointment_drawer_refreshing_1440x900.png | **EXACT** | “正在刷新可用号源...”与 overlay spinner 完整,确认禁用。 |
|
||||
|
||||
## 服务端合同限制:不归入视觉 P1
|
||||
|
||||
当前服务端只提供“当日追加 / 查询 / 删除单个附件”,没有“任意覆盖既有正文”或“删除整条笔记”端点:
|
||||
|
||||
- D:/web/zyt/server/app/adminapi/controller/doctor/AppointmentController.php:168-196 只公开 addDoctorNote、doctorNotes、deleteDoctorNoteImage。
|
||||
- D:/web/zyt/server/app/adminapi/logic/doctor/DoctorNoteLogic.php:14-69 按 diagnosis_id + 当天 find-or-create,并把新正文追加为新行,不是任意覆盖。
|
||||
- D:/web/zyt/server/app/adminapi/logic/doctor/DoctorNoteLogic.php:114-136 只删除 tongue_images / report_files 中的单个路径。
|
||||
- D:/web/zyt/admin/src/api/patient.ts:19-40 同样只有上述三个客户端 API。
|
||||
- Python 远端仓库与该合同一致:src/doctor_workstation/services/repository.py:935-985。
|
||||
|
||||
因此桌面端没有伪造“覆盖旧正文 / 删除整条笔记”按钮是正确的 fail-closed 行为。该限制不能被当作视觉 PASS 的理由,也不应混入上面 4 个仍可修的 UI P1。
|
||||
|
||||
## 最终判定
|
||||
|
||||
- **P0:0 OPEN。**
|
||||
- **P1:4 OPEN / PARTIAL**:Daily 动作行与 scoped 按钮语义;完整菜单图标/删除色/文案;Notes 附件视觉;处方/订单/视频/聊天视觉与证据覆盖。
|
||||
- **MISSING 视觉证据**:当前 diagnosis_visual 没有滚动到 switch 的截图,也没有完整处方编辑器和订单详情抽屉截图;源码能力存在,但不能用测试代替这些像素证据。
|
||||
- **乱码 / 缺字:0。** 43 张图中的中文、圆弧 loading、箭头、保存勾叉均完整。
|
||||
- **最终:PARTIAL。** 只有消除上述 4 组可见差异并补齐处方编辑器、订单详情与 switch 的当前截图后,才可给视觉同型 PASS。
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
# Python 医生工作站诊单视觉 / 交互差异审计
|
||||
|
||||
> 审计日期:2026-08-10
|
||||
> 结论:**OPEN — 当前实现不具备与管理端诊单主链做截图级验收的条件。** 主要差异不是局部颜色或间距,而是索引页层级、表格固定列、只读/编辑形态和预约选择器的组件架构不同。
|
||||
> 约束:本轮只读检查业务源码;只新增本审计文档,未修改 Python、Vue、接口、payload、权限、状态门槛或业务流程。
|
||||
> 环境说明:项目提示的 `D:/web/zyt/app/.trellis/` 当前不存在,因此没有可补读的 Trellis 工作流或包级规格。
|
||||
|
||||
## 1. 范围与事实源
|
||||
|
||||
当前 Python 审计范围:
|
||||
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/theme.py`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/widgets.py`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/shell.py`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py` 中 `_AppointmentDialog` 及其入口
|
||||
|
||||
管理端唯一视觉事实源:
|
||||
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue`
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue`
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/readonly.vue`
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/appointment.vue`
|
||||
- 上述页面直接渲染的 diagnosis components、`NoteTimeline.vue` 以及直接生效的全局样式
|
||||
|
||||
研究基线:
|
||||
|
||||
- `D:/web/zyt/app/research/diagnosis_index_visual_spec.md:1-373`
|
||||
- `D:/web/zyt/app/research/diagnosis_detail_visual_spec.md:1-565`
|
||||
|
||||
明确排除:其他角色页面、小程序、企微运营、二维码、视频观看弹窗、未被三主视图引用的旧 Blood/Diet/Exercise/Tracking 组件,以及任何业务接口改造。本报告提到权限时,只描述入口或 Tab 的可见状态,不重新设计权限规则。
|
||||
|
||||
## 2. 总体判定
|
||||
|
||||
| 领域 | 当前状态 | 阻断截图复刻的根因 |
|
||||
|---|---|---|
|
||||
| 全局主题与密度 | OPEN | Qt 是暖灰绿、13 px、36 px 控件;管理端诊单基线是冷灰白、14 px、32 px 控件,索引主色为 `#4A5DFF` |
|
||||
| 诊单索引 | OPEN | 多出页面标题;筛选永久四行;单表无右固定列;行操作、复合单元格、分页、空态和 loading 架构不同 |
|
||||
| 诊单只读 / 编辑 | OPEN | 独立 readonly 与 drawer viewOnly 被合并成同一个居中 `QDialog`;Tabs、字段布局和内容主链大量不等价 |
|
||||
| 预约 | OPEN | 60% 右 Drawer 被固定 540×560 Dialog 取代;医生、日期、时段网格全部降级为 ComboBox |
|
||||
| 1024×640 | OPEN | 880×680 诊单 Dialog 请求高度超过外窗;索引页无页面级滚动且高级筛选常驻,表格可见高度过低 |
|
||||
| 1440×900 | OPEN | 索引仍缺固定 460 px 右区;详情/预约保持固定宽度,无法随视口保持 60% 比例 |
|
||||
|
||||
必须先完成四项结构改造,再做颜色和像素微调:
|
||||
|
||||
1. 诊单索引改回“无页面标题 + 两张平卡 + 折叠筛选”。
|
||||
2. 诊单表使用专用 model/delegate,并实现右侧 `120 + 340 = 460 px` 固定区域。
|
||||
3. 将独立 readonly 页面与 edit/viewOnly Drawer 拆成两个视觉形态。
|
||||
4. 将预约恢复为右侧 Drawer,并恢复医生 radio、日期按钮、时段卡片网格及完整状态。
|
||||
|
||||
## 3. 基础 token 与壳层差异
|
||||
|
||||
### G-01 [阻断] 字体、颜色、控件与圆角是另一套设计系统
|
||||
|
||||
管理端诊单索引基线为 `PingFang SC, Arial, Hiragino Sans GB, Microsoft YaHei`、基础 14 px、页面 `#F6F6F6`、卡片 `#FFFFFF`、主/常规/次要文字 `#333/#666/#999`、默认控件 32 px、small 24 px、索引主色 `#4A5DFF`、卡片 r4。证据:
|
||||
|
||||
- `D:/web/zyt/admin/src/config/setting.ts:1-14`
|
||||
- `D:/web/zyt/admin/src/styles/var.css:1-43`
|
||||
- `D:/web/zyt/app/research/diagnosis_index_visual_spec.md:65-101`
|
||||
|
||||
当前 Qt 为 Microsoft YaHei UI 优先、基础 13 px、画布/表面 `#F4F3EF/#FCFBF8`、墨绿/青绿 `#17382F/#168579`、按钮和输入最小 36 px/r9、卡片 r16。状态色也全部偏成低饱和绿褐色。证据:
|
||||
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/theme.py:13-33`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/theme.py:36-84`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/theme.py:87-175`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/theme.py:233-250`
|
||||
|
||||
可见后果:同一页面的字宽、首屏密度、按钮高度、卡片轮廓、语义色和表格行数均不同;仅替换主色不能修复。
|
||||
|
||||
建议边界:在 `D:/web/zyt/app/src/doctor_workstation/ui/theme.py:13-275` 增加以 `#DiagnosisIndex`、`#DiagnosisDrawer`、`#DiagnosisReadonly`、`#AppointmentDrawer` 为根的局部 token/QSS。除非另立全工作站换肤任务,不应直接改写共享 `Card`、`QPushButton`、`QTableWidget` 的全局规则。
|
||||
|
||||
### G-02 [高] 壳层占宽与页面滚动模型不同
|
||||
|
||||
当前 Shell 最小 1024×640,侧栏固定 216 px、顶栏固定 68 px,workspace 的 `QStackedWidget` 本身没有页面滚动容器:
|
||||
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/shell.py:306-325`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/shell.py:329-423`
|
||||
|
||||
管理端默认侧栏宽 183 px,navbar 50 px,默认还可显示 40 px multiple tabs;路由内容由 `el-scrollbar` 承担整页纵向滚动,页面外留白为 8 px 水平 / 16 px 垂直:
|
||||
|
||||
- `D:/web/zyt/admin/src/config/setting.ts:1-6`
|
||||
- `D:/web/zyt/admin/src/styles/var.css:4-10`
|
||||
- `D:/web/zyt/admin/src/layout/default/components/header/multiple-tabs.vue:65`
|
||||
- `D:/web/zyt/admin/src/layout/default/components/main.vue:2-11`
|
||||
|
||||
当前诊单页又额外使用 24 px 水平边距,因此两档窗口下的静态内容宽约为:
|
||||
|
||||
| 外窗 | 当前 workspace | 当前页面内容宽 | 管理端默认路由内容宽(约) | 差值 |
|
||||
|---|---:|---:|---:|---:|
|
||||
| 1024×640 | 808 | 760 | 824 | -64 px |
|
||||
| 1440×900 | 1224 | 1176 | 1240 | -64 px |
|
||||
|
||||
建议边界:索引页自行增加页面级滚动及 8/16 外留白;不要为了修诊单页直接改变全工作站 216/68 壳。若最终验收要求连管理端壳一起复刻,应另行冻结侧栏、navbar、multiple tabs 的 setting 后再改 `ShellWindow`。
|
||||
|
||||
### G-03 [高] hover、checked、focus、read-only 状态不完整
|
||||
|
||||
- 日期和业务入口都是 checkable ghost `QPushButton`,但 QSS 没有 `QPushButton[variant="ghost"]:checked`;active 态没有确定的可见反馈。当前:`D:/web/zyt/app/src/doctor_workstation/ui/theme.py:87-118`、`D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:495-528,776-799`;基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:2227-2308`。
|
||||
- Qt 输入 focus 直接把 1 px 边改为 2 px 青绿实线,存在内部尺寸跳动;管理端是 primary-light 的 2 px 外环。当前:`D:/web/zyt/app/src/doctor_workstation/ui/theme.py:140-154`;基准:`D:/web/zyt/admin/src/styles/element.scss:140-175`。
|
||||
- 按钮、Tab、radio、checkbox 没有统一的键盘 focus ring;表格还显式 `outline:0`。当前:`D:/web/zyt/app/src/doctor_workstation/ui/theme.py:87-138,164-186,208-220`。
|
||||
- read-only `QPlainTextEdit` 没有独立 QSS,仍呈白底可编辑外观;只定义了部分 `:disabled`。当前:`D:/web/zyt/app/src/doctor_workstation/ui/theme.py:140-155`、`D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:685-689`。
|
||||
- 表格没有显式 item hover 规则;管理端普通 hover 是 `#F8F8F8`,业务行还有语义渐变。当前:`D:/web/zyt/app/src/doctor_workstation/ui/theme.py:177-197`;基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:2723-2735`。
|
||||
|
||||
建议边界:为诊单专用 chip、输入、Tab、表格 delegate 增加状态规则;保留键盘可达性,不必复刻 HTML `span` 不可聚焦的缺陷。focus 不应通过改变边框占位宽度实现。
|
||||
|
||||
### G-04 [中] 共享组件的视觉契约与诊单基线不同
|
||||
|
||||
- `PageHeader` 固定生成标题/副标题层级:`D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:331-365`。
|
||||
- `EmptyState` 是 30 px 圆圈 + 16 px 标题 + 说明 + 44 px 垂直留白:`D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:388-420`。
|
||||
- `BusyOverlay` 是文字 + 140 px 横向 progress,非 42 px 环形 spinner:`D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:489-514`、`D:/web/zyt/app/src/doctor_workstation/ui/theme.py:230-231,271`。
|
||||
- `SortableTable` 是单选、全列本地排序、纯文本 item、末列 stretch:`D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:535-575`。
|
||||
- `Pager` 只有 total、上一页、页码分数、下一页:`D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:585-627`。
|
||||
|
||||
建议边界:新增诊单专用组件,不要破坏性修改这些被患者、订单、接诊页共享的类。
|
||||
|
||||
## 4. 诊单索引页差异
|
||||
|
||||
### I-01 [阻断] 页面层级、留白和卡片形态错误
|
||||
|
||||
管理端页面内没有标题或说明;路由内容直接从无边框、无阴影、r4 的筛选白卡开始,第二张列表卡相距 12 px。新增诊单在列表卡工具栏:
|
||||
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:2-177`
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:2200-2211,2408-2434`
|
||||
|
||||
当前页面先放 25 px `PageHeader("问诊列表", …)`,并把新增按钮放在标题右侧;外边距 24/20/24、间距 14。两张卡是有边框的暖白 r16,表格自身再加 r12,形成“卡中卡”:
|
||||
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:464-587`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:590-674`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/theme.py:54-84,177-197`
|
||||
|
||||
建议边界:只在 `ConsultationsPage.__init__` 移除页面内 `PageHeader`,将 add 入口移入列表工具栏;保留 Shell topbar。诊单两卡使用局部 objectName/QSS。
|
||||
|
||||
### I-02 [阻断] 日期 / 业务 chip 顺序、文案、换行和语义态不同
|
||||
|
||||
管理端顺序严格是“昨天挂号、前天挂号、当天挂号、明天挂号、后天挂号、全部”,随后是待预约/已完成/待分配医助;左侧 chips 可换行,计数是同行半透明文字。三种业务 chip 分别用 info/success/warning:
|
||||
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:6-70`
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:964-970`
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:2222-2317`
|
||||
|
||||
当前顺序是“前天、昨天、今天、明天、后天、全部”,缺“挂号”文案;九个按钮放在不可换行的 `QHBoxLayout`,全部是通用 ghost,无业务色和明确 checked 态:
|
||||
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:483-530`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:776-799`
|
||||
|
||||
在 1024 档,按钮加入两三位计数后可能在约 760 px 内容宽内被挤压或裁字。
|
||||
|
||||
建议边界:增加诊单局部 `FlowLayout` 和带 dynamic property 的 chip;沿用现有 `_choose_*` 与 `_update_quick_buttons()`,只换视图装配和文案。
|
||||
|
||||
### I-03 [阻断] 筛选的两层结构被永久四行网格取代
|
||||
|
||||
管理端:第一行右侧是固定 160 px 搜索 + 32 px primary 查询;第二行是“挂号/确认”小 chips 和“更多筛选”;高级区默认隐藏,点击后即时显隐,使用虚线上边界和可换行布局:
|
||||
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:72-151`
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:2320-2405`
|
||||
|
||||
当前:关键词、挂号、确认和所有高级条件永久铺成四行等宽 `QGridLayout`;查询/重置在最后一行。挂号/确认变成 ComboBox;最近日期范围被拆成四个独立 `QDateEdit`;还常驻一个管理端通过表头表达的“未服务排序”下拉:
|
||||
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:532-586`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:683-721`
|
||||
|
||||
当前还缺待分配模式选中后显示的月份 128 px 和宽搜索 220 px / max 42vw;管理端证据为 `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:40-70,2298-2317`。
|
||||
|
||||
建议边界:重建筛选视图,但保留 `_search()`、`_filters()`、日期校验和现有请求字段。是否补待分配月份/宽搜索涉及现有请求参数,超出本视觉报告授权;在业务层确认前先预留容器,不自行发明接口字段。
|
||||
|
||||
### I-04 [阻断] 列表工具栏与选择模型不等价
|
||||
|
||||
管理端列表栏无“诊单记录”标题;左侧是新增、批量指派、批量取消指派,右侧按需显示“已选 n 条”。表格首列为 48 px checkbox 多选:
|
||||
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:154-191`
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:2414-2429`
|
||||
|
||||
当前工具栏是标题 + 总数摘要 + 查看/诊单/开方/作废/视频/删除/刷新等全局按钮;表格无 checkbox,使用整行单选,加载后自动选第一行并出现青绿选择底:
|
||||
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:590-640`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:938-946`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:1085-1112`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:541-549`
|
||||
|
||||
建议边界:视图层改为 checkbox check-state;不要用整行 selection 代替多选。批量动作若当前业务方法不存在,本轮只应建立视觉容器和禁用态,不得伪造 mutation。
|
||||
|
||||
### I-05 [阻断] 表格列、固定区、排序和行操作架构不同
|
||||
|
||||
管理端列顺序与宽度为:选择 48、ID min70、患者 min60、性别年龄 100、挂号 min175、确认 88、复诊 min120、助理 100、开方 72、未服务 110、视频旁观 120 fixed、操作 340 fixed。声明最小总宽约 1403 px,右固定区共 460 px:
|
||||
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:191-406`
|
||||
- `D:/web/zyt/app/research/diagnosis_index_visual_spec.md:169-186`
|
||||
|
||||
当前列约 1392 px,但无选择/操作列,额外有医生 90、作废 74;挂号 300、未服务 180、视频 90。所有列随单一 `QTableWidget` 一起滚动,末列还会 stretch:
|
||||
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:646-663`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:535-552`
|
||||
|
||||
管理端只允许“未服务天数”走 descending → ascending 的自定义排序;当前全部表头都可做客户端排序,并另设排序 ComboBox:
|
||||
|
||||
- 基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:297-304`
|
||||
- 当前:`D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:543-549`、`D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:560-570`
|
||||
|
||||
管理端每行右侧有查看、诊单、开方/查看处方、预约、补全身份证和“更多”菜单;当前把少量动作集中在列表栏,行内没有动作命中区:
|
||||
|
||||
- 基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:342-403`
|
||||
- 当前:`D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:605-639`
|
||||
|
||||
建议边界:新增 `DiagnosisTableModel`、专用 delegates 和同步的双 `QTableView`;右表只显示 120/340 两列,共享 model、selection、垂直滚动与 row height。不要把固定列能力硬塞进通用 `SortableTable`。
|
||||
|
||||
### I-06 [阻断] 复合单元格、语义色、hover 和密度全部降级为纯文本
|
||||
|
||||
当前 `SortableTable.set_rows()` 只创建 `QTableWidgetItem(text)`;格式化函数也只拼接字符串:
|
||||
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:554-575`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:233-322`
|
||||
|
||||
由此缺失:
|
||||
|
||||
- ID/患者 14 px/600;`NEW` 20 px danger tag。
|
||||
- 挂号块的 11 px badge、14 px/600 医生、12 px 时间、状态渐变、多预约虚线、取消挂号链接和最近渠道。
|
||||
- 已确认/未确认、已开方/未开方、处方作废 tag。
|
||||
- 未服务 null/`<=2`/`3–6`/`>=7` 的灰/绿/橙/红,以及健康打卡 tooltip。
|
||||
- 普通白/#FAFAFA 斑马、#F8F8F8 hover、未确认/未挂号的 3 px 左条和渐变。
|
||||
- 行级视频入口和 340 px 动作链接区。
|
||||
|
||||
管理端证据:
|
||||
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:193-406`
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:2442-2735`
|
||||
|
||||
当前表格头是 12 px/700、`#EEF1EE`,alternate 为暖灰 `#F6F7F4`;基准表头为 13 px/600、`#F8F8F8`,普通预约行约 52 px。当前只调用 `resizeRowsToContents()`,无 39/52 px 基线:
|
||||
|
||||
- 当前:`D:/web/zyt/app/src/doctor_workstation/ui/theme.py:177-197`、`D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:938-940`
|
||||
- 基准:`D:/web/zyt/app/research/diagnosis_index_visual_spec.md:163-212`
|
||||
|
||||
建议边界:复合绘制与 `sizeHint()` 均放在诊单 delegates;不要给每格常驻 QWidget。行样本必须覆盖多预约和动作换行,且双表 row height 同步。
|
||||
|
||||
### I-07 [高] 分页、空态、loading、error 的布局契约不同
|
||||
|
||||
分页基线为 total、15/20/30/40 size、prev、最多 5 个数字页、next、jumper,按钮 32 px;当前只有 total、上一页、`1/N`、下一页,按钮最小 36 px:
|
||||
|
||||
- 基准:`D:/web/zyt/app/research/diagnosis_index_visual_spec.md:254-262`
|
||||
- 当前:`D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:585-627`
|
||||
|
||||
空态基线保留表头和分页,仅在 table body 显示约 60 px 高的“暂无数据”。当前切换整个 `content_stack` 到大型 `EmptyState`,表头与分页同时消失:
|
||||
|
||||
- 基准:`D:/web/zyt/app/research/diagnosis_index_visual_spec.md:264-273`
|
||||
- 当前:`D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:642-673`、`D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:388-420`
|
||||
|
||||
loading 基线只覆盖表格,亮色 `rgba(255,255,255,.5)` + 42 px 主色环;20 秒静默刷新不闪。当前普通加载在两卡之间插入 MessageBanner,并禁用刷新,没有 table overlay;共享 `BusyOverlay` 的形态也不匹配:
|
||||
|
||||
- 基准:`D:/web/zyt/app/research/diagnosis_index_visual_spec.md:275-280`
|
||||
- 当前:`D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:901-956`、`D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:489-514`
|
||||
|
||||
错误当前只显示全宽 Banner,原表/旧数据仍在;管理端主表没有独立 error 插画。建议保留错误消息但不得让 Banner 插入造成卡片整体跳位。
|
||||
|
||||
建议边界:新增诊单专用 PaginationBar、table viewport empty label 和 table-only overlay;保持现有 generation、silent refresh 与错误传递逻辑不变。
|
||||
|
||||
### I-08 [高] 页面纵向滚动与少量数据高度不同
|
||||
|
||||
管理端表格没有本页固定高度;15 行自然撑高并由路由 `el-scrollbar` 滚整页。当前列表卡以 stretch=1 填满余高,表格内部滚动;一行数据时会留下大块空白,15 行时筛选和工具栏固定在窗口而不是随页面滚动:
|
||||
|
||||
- 基准:`D:/web/zyt/admin/src/layout/default/components/main.vue:2-11`、`D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:179-190`
|
||||
- 当前:`D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:642-674`
|
||||
|
||||
建议边界:诊单页使用单一 `QScrollArea` 承载两卡,表格按 header + 实际 rows + pagination 给自然高度;不要改其他页面的内部表滚动策略。
|
||||
|
||||
## 5. 诊单只读 / 编辑差异
|
||||
|
||||
### D-01 [阻断] 两种只读形态被合并为一个居中 Dialog
|
||||
|
||||
管理端有两个不可互换的只读形态:
|
||||
|
||||
1. `readonly.vue`:独立单列页面,无 Tabs,Hero 后按顺序纵向展示摘要、病例、日常记录、备注、订单、视频、聊天、指派、挂号。
|
||||
2. `edit.vue` 的 `viewOnly`:仍是 60% 右 Drawer + Tabs + disabled 表单,只隐藏保存。
|
||||
|
||||
证据:
|
||||
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/readonly.vue:1-149`
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:1-84,766-781`
|
||||
- `D:/web/zyt/app/research/diagnosis_detail_visual_spec.md:75-152,156-194`
|
||||
|
||||
当前所有查看/编辑都调用同一个 `DiagnosisDialog.open_for()`;只通过 `setReadOnly` 和保存按钮显隐切模式:
|
||||
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:245-288`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:426-464`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:1113-1129`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:2142-2151`
|
||||
|
||||
建议边界:保留 `open_for()`、saved signal、现有 load bundle 和数据快照;仅拆视图为 `DiagnosisReadonlyView` 与 `DiagnosisDrawerView`,由入口决定呈现形态。不得通过给现有 QDialog 换皮来声称两种基线都已覆盖。
|
||||
|
||||
### D-02 [阻断] Drawer 几何与 Header/Body/Footer 三段壳缺失
|
||||
|
||||
编辑/viewOnly 基线为 RTL 右 Drawer、宽视口 60%、全高;header 为白到 `#F8FAFC` 渐变和底线,body `#F8FAFC`,footer 独立白底、上边线,按钮右对齐、40 px/r10:
|
||||
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:3-10`
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:1670-1745`
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:1776-1864`
|
||||
|
||||
当前是居中系统 `QDialog`,请求 880×680、最小 680×520,统一暖灰画布,四周 20/18;标题 25 px。按钮栏虽在 Tabs 外,能保持不随首 Tab 滚动,但没有 footer frame、边线和确定按钮顺序:
|
||||
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:245-288`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/theme.py:44-46,54-58,87-104`
|
||||
|
||||
当前 mode 文案/颜色也不同:后台只读 info、编辑 warning、新建 success;当前只读 neutral、可编辑 success,且标题是“患者信息详情/编辑患者病历”:`D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:256-266,441-447`。
|
||||
|
||||
建议边界:在主窗之上实现遮罩 + 右贴边 panel,按外窗宽度计算 60%;只让 body 滚动,header/footer 固定。保留 QDialog 作为临时承载会继续出现系统标题栏和居中几何差异。
|
||||
|
||||
### D-03 [阻断] Tabs 样式、顺序、权限可见性和内容主链不等价
|
||||
|
||||
管理端顺序:病历、医生备注、日常记录、处方、业务订单、视频录制回放、聊天、指派、挂号;“跟踪备注”已注释,不渲染。Tabs 为可横滚文本导航,4 px scrollbar,active 3 px 底条:
|
||||
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:55-759`
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:1798-1858`
|
||||
|
||||
当前只有患者与病历、医生备注、挂号记录、指派记录、可选患者订单;挂号先于指派,日常记录/待办、处方、视频、聊天均缺失。除订单外,Tab 没按对应权限移除:
|
||||
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:271-279`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:479-525`
|
||||
|
||||
当前全局 Tab 是青绿填充 pill、r9、36 px,无 3 px active indicator:`D:/web/zyt/app/src/doctor_workstation/ui/theme.py:208-220`。
|
||||
|
||||
建议边界:用诊单局部 `QTabBar + QStackedWidget` 或可横滚 TabBar;按既有 canonical permissions 移除入口,当前 Tab 失权时回病历。不要加入已注释 TrackingNote,也不要重复增加旧 Blood/Diet/Exercise Tabs。
|
||||
|
||||
### D-04 [阻断] 编辑表单被统一成单列大文本框,字段顺序也偏离真实模板
|
||||
|
||||
当前 `_DIAGNOSIS_FIELDS` 将全部字段装成单列 `QFormLayout + QPlainTextEdit`,每项 46–82 px;没有 160 px label、栅格、分组标题或语义控件:
|
||||
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:49-102`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:331-346`
|
||||
|
||||
管理端是 12 px 表单、160 px label、32 px input/select/date/number、radio/check-button、24/12/8 栅格、18 px 项间距,以及横线夹胶囊的分组标题:
|
||||
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:57-632`
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:1476-1667`
|
||||
|
||||
当前与后台真实模板的具体偏差:
|
||||
|
||||
- 缺少/不等价:诊单ID表单行、状态、渠道、统计端就诊卡、当地诊断日期、糖尿病病史、口腔感觉、其他补充、病史补充。
|
||||
- “当前用药”被放在末尾;后台“在用药物”位于生命体征区。
|
||||
- 当前显示后台模板未渲染的证型、糖尿病类型、自由文本主诉/现病史/症状、食欲、舌象、舌苔、脉象、临床诊断、治则、处方、处方意见、医嘱。
|
||||
- 类别项都变成自由文本,失去 radio/check/select 的密度和状态。
|
||||
|
||||
真实后台字段次序见 `D:/web/zyt/app/research/diagnosis_detail_visual_spec.md:221-255`。
|
||||
|
||||
患者基本信息锁定时,当前只设 read-only,没有后台 warning Alert:
|
||||
|
||||
- 当前:`D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:659-689`
|
||||
- 基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:71-84`
|
||||
|
||||
建议边界:只重建 `DiagnosisDialog` 的表单装配和 scoped QSS,保留字段值转换、校验、保存入口及隐私遮罩。严格按后台实际渲染字段,不把脚本模型中但模板未显示的字段继续呈现。
|
||||
|
||||
### D-05 [阻断] 独立 readonly 的 Hero、卡片流和病例网格完全缺失
|
||||
|
||||
readonly 基线:16 px 页面边距/卡间距;可换行浅蓝 Hero 带返回、患者名、性别年龄、未服务绿橙红 badge;白卡 r14、18 px padding、标题前 3×16 蓝条;患者摘要按确定顺序,病例按 4/3 列分组键值展示:
|
||||
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/readonly.vue:1-149`
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/readonly.vue:257-405`
|
||||
- `D:/web/zyt/app/research/diagnosis_detail_visual_spec.md:75-152`
|
||||
|
||||
当前没有返回、Hero、未服务 badge 或纵向卡片流。摘要是两列九项,额外含身份证/血压/血糖,却缺地区、预约状态、是否开方和备注;病例仍是单列可编辑形态:
|
||||
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:290-349`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:574-700`
|
||||
|
||||
建议边界:单独实现只读卡片 view;不要把 disabled 编辑表单当作独立 readonly。1440 档保留后台 4/3 列;1024 档若主动降列,应在验收中标注为桌面可用性增强,而不是声称 Vue 原实现已有响应式。
|
||||
|
||||
### D-06 [高] 备注、订单、历史表和关联内容被降级或缺失
|
||||
|
||||
- 医生备注:后台是时间轴、64×64 缩略图、文件 chip、编辑动作;当前仅三列“时间/医生/内容”。基准 `D:/web/zyt/admin/src/views/patient/reception/components/NoteTimeline.vue:1-436`;当前 `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:351-364`。
|
||||
- 日常记录/待办:后台有日期筛选、矩阵、趋势、toolbar 和待办;当前整个 Tab 缺失。基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/components/DailyMatrix.vue:1-1260`、`D:/web/zyt/admin/src/views/tcm/diagnosis/components/DiagnosisTodoList.vue:1-319`。
|
||||
- 处方历史:后台七列并有编辑态开方入口;当前缺失。基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/components/CaseRecordList.vue:1-106`。
|
||||
- 业务订单:后台有就诊序号工具栏、九列、Tag/红金额、固定详情;当前八列为订单号/处方/收货人/手机/金额/发货/状态/创建,无工具栏和详情动作。当前 `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:393-424,764-787`;基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/components/PatientOrderList.vue:7-148,252-276`。
|
||||
- 指派历史:后台九列;当前六列且继承只是纯文本。当前 `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:378-390,748-762`;基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/components/AssignLogPanel.vue:1-43`。
|
||||
- 挂号历史:后台十二列和多个状态 Tag;当前七列。当前 `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:370-390,731-746`;基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/components/AppointmentRecordPanel.vue:1-75`。
|
||||
- 视频与聊天:当前完全缺失。基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/components/CallRecordPanel.vue:1-214`、`D:/web/zyt/admin/src/views/tcm/diagnosis/components/ImChatRecordPanel.vue:1-293`。
|
||||
|
||||
当前所有表格最终都只创建纯文本 `QTableWidgetItem`:`D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:711-716`。
|
||||
|
||||
建议边界:按当前主视图可达组件增加局部 model/delegate;不把不可达旧组件或观看弹窗混入。缺失内容若尚无当前 repository 数据来源,应明确保持 OPEN,不能用静态假数据冒充视觉完成。
|
||||
|
||||
### D-07 [高] loading、empty、error、保存与隐私锁定状态不足
|
||||
|
||||
- 打开详情时只显示 MessageBanner,种子内容继续可见;没有 readonly 页面级 overlay/skeleton:`D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:452-464`。
|
||||
- 加载失败只改 Banner,后台独立 readonly 应在 Hero 下显示错误 Empty:当前 `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:570-573`;基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/readonly.vue:31-36`。
|
||||
- 清空表格只是 `setRowCount(0)`,没有“暂无…”状态:`D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:702-709`。
|
||||
- 订单翻页仍用全局 Banner,未遮住对应 Tab 内容:`D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:789-827`。
|
||||
- 保存只禁用按钮并显示 Banner,没有按钮 loading 动画或字段级 danger:`D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:829-917`。
|
||||
- 隐私/业务锁定只有只读控件,无 warning Alert,且 read-only 控件视觉仍像可编辑白框:`D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:659-689`、`D:/web/zyt/app/src/doctor_workstation/ui/theme.py:140-155`。
|
||||
|
||||
建议边界:为每个内容区域提供局部 overlay/empty/error;保留 seed-first、generation 和异常处理。错误样式不能改变业务成功/失败判定。
|
||||
|
||||
## 6. 预约差异
|
||||
|
||||
### A-01 [阻断] 预约 Drawer 被固定居中 Dialog 取代
|
||||
|
||||
基线为 RTL 右 Drawer、外窗 60% 宽、全高、遮罩点击不能关闭、独立 footer;表单 label 100 px:
|
||||
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/appointment.vue:2-19,185-198`
|
||||
|
||||
当前是居中 540×560 模态 QDialog、20/18 内边距、系统标题栏、默认 QFormLayout label 宽;内容顶部还额外放 25 px 患者名和“患者号仅用于视频”说明:
|
||||
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:219-299`
|
||||
|
||||
建议边界:保留 `_AppointmentDialog` 的异步查询、状态字段、`payload()` 和 `accept()`;只把承载层替换为右贴边 panel,并将 Header/Body/Footer 分离。不要改 `PatientsPage._book_appointment()` 的业务调用:`D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:2208-2224`。
|
||||
|
||||
### A-02 [阻断] 字段顺序和控件类型不等价
|
||||
|
||||
后台严格顺序是:上次就诊、预约方式、预约类型、当前患者、渠道、条件性自媒体补充、医生 radio、日期按钮、时段矩阵、备注:
|
||||
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/appointment.vue:20-181`
|
||||
|
||||
当前缺上次就诊、预约方式和当前患者;预约类型是 ComboBox,医生/日期/号源也全是 ComboBox:
|
||||
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:247-285`
|
||||
|
||||
渠道基线宽 100%、最大 360 px;当前随 QFormLayout 拉伸,没有最大宽。`channel_source_detail` 创建后默认可见,初始化信号又被 `_setting` 拦截,因此首帧/默认渠道可能错误显示“渠道补充”;只有后续用户 change 才显隐:
|
||||
|
||||
- 当前:`D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:260-268,345-402,587-594`
|
||||
- 基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/appointment.vue:62-75,741-750`
|
||||
|
||||
建议边界:按真实顺序重建表单控件;沿用当前字段与校验,不改变 payload key。条件控件在初始渲染时就必须正确隐藏。
|
||||
|
||||
### A-03 [阻断] 日期和时段的所有可见状态都被下拉框吞掉
|
||||
|
||||
后台医生为可换行 radio;日期按钮 min 130×40、r8、14 px,hover 上移 2 px并有阴影;时段容器为 `#F8F9FA`、16 px padding,网格 cell min 110×70,显式展示可约、不可约、hover、selected 和刷新 loading:
|
||||
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/appointment.vue:77-171`
|
||||
- `D:/web/zyt/admin/src/views/tcm/diagnosis/appointment.vue:752-921`
|
||||
|
||||
当前只将医生、日期、可用号源放进 ComboBox;`_apply_slots()` 直接过滤不可约、quota<=0 和当天过去时段,因此不可约卡、状态标签、slot hover、selected 渐变和刷新入口均无法呈现:
|
||||
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:269-280`
|
||||
- `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:534-574`
|
||||
|
||||
建议边界:以服务端原始 slot 列表驱动局部 Flow/Grid;业务上不可提交的时段保留为 disabled card。当前的未来日期限制、过期判定和 submit gate 保持不变。
|
||||
|
||||
### A-04 [高] loading、空态、重复预约、hover/focus 状态不足
|
||||
|
||||
- 后台未选医生/无排班在时间区显示 Empty;当前只是 ComboBox placeholder + Banner:基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/appointment.vue:100-171`;当前 `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:410-493`。
|
||||
- 后台 `v-loading` 覆盖预约 body;当前所有阶段复用表单下方 MessageBanner,没有区域 overlay:基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/appointment.vue:10`;当前 `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:286-299,439-448,521-532`。
|
||||
- 当天冲突基线在顶部持续显示 warning,切其他日期显示 info;当前只计算今日 blocking 并通过提交禁用/accept 文案表达,没有持续的 warning/info 双态:当前 `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:319-402,600-643`;基准 `D:/web/zyt/admin/src/views/tcm/diagnosis/appointment.vue:10-18,359-386`。
|
||||
- 后台 available hover、disabled opacity、selected 蓝渐变和 slot refresh loading 当前均不存在。
|
||||
- 当前 generic focus 仍是青绿 2 px 实边,radio/date/slot 的 focus-visible 不存在。
|
||||
|
||||
建议边界:增加预约 body overlay、时间区 empty、顶部冲突 alert、按钮/卡片 hover/pressed/focus;保留现有 `_today_blocking` 与 submit complete 判定。
|
||||
|
||||
### A-05 [高] 预约响应式和滚动安全不足
|
||||
|
||||
基线预约 Drawer 自身没有 <=768 media query,但内部医生、日期、slot 可换行,body 是 Drawer 可滚区域。当前固定 540×560 且无 `QScrollArea`;恢复完整 slot 网格后若继续用当前容器,1024×640 必然裁掉内容或 footer。
|
||||
|
||||
建议边界:桌面保持 60% 比例;当可用内容宽不足时再做全宽/单列增强,并明确这是 PySide 可用性增强。body 独立滚动,footer 永远留在可视区。
|
||||
|
||||
## 7. 两档窗口的确定性差异
|
||||
|
||||
### 1024×640
|
||||
|
||||
| 视图 | 管理端基线 | 当前源码请求 | 必须验收的风险 |
|
||||
|---|---|---|---|
|
||||
| 索引 | 页面内容可整体纵向滚;表宽约1403,右460固定 | 内容约760;高级筛选四行常驻;单表约1392且无固定列 | chip 计数裁字、表格高度仅剩少数行、动作不可见、空/loading移位 |
|
||||
| edit/viewOnly | 右贴边约614 px、全高、固定 footer | 居中880×680,最小680×520 | 请求高度已超过640;系统窗口可能裁剪/强制缩放,footer不可作为稳定截图基线 |
|
||||
| appointment | 右贴边约614 px、全高 | 居中540×560 | 宽度偏小且大量留白;恢复slot后无滚动会裁内容 |
|
||||
| standalone readonly | 页面流、16px边距、无Tabs | 880px模态Tabs | 模式、层级和宽度均不可比较 |
|
||||
|
||||
### 1440×900
|
||||
|
||||
| 视图 | 管理端基线 | 当前 | 必须验收的风险 |
|
||||
|---|---|---|---|
|
||||
| 索引 | 内容宽约1240;仍可能横滚,右460固定 | 内容约1176;横滚约248px且无固定列 | 视频/操作随滚动消失;少量数据时列表卡被拉出大空白 |
|
||||
| edit/viewOnly | 右贴边约864 px | 居中880×680 | 宽度偶然接近,但方向、全高、三段壳仍错误 |
|
||||
| appointment | 右贴边约864 px | 居中540×560 | 明显过窄,无法呈现日期/slot网格密度 |
|
||||
| standalone readonly | 宽页面流,病例4/3列 | 固定880px单列表单 | 信息密度和卡片层级完全不同 |
|
||||
|
||||
注:Element Drawer 的 60% 按外窗 viewport 计算,因此上表为约 614/864 px;不按 Python 扣除侧栏后的 workspace 计算。
|
||||
|
||||
## 8. 建议改动边界(按文件 / 类)
|
||||
|
||||
| 目标 | 允许的视觉改造 | 必须保持不动 |
|
||||
|---|---|---|
|
||||
| `D:/web/zyt/app/src/doctor_workstation/ui/theme.py:13-275` | 新增诊单局部 token、scoped QSS、checked/focus/read-only/overlay/delegate 状态 | 不全局换肤其他页面;不改变业务状态含义 |
|
||||
| `D:/web/zyt/app/src/doctor_workstation/ui/widgets.py:331-627` | 新增 FlowLayout、DiagnosisPaginationBar、局部 loading/empty、专用 model/delegate/固定表辅助 | 不破坏现有 PageHeader、EmptyState、SortableTable、Pager 的调用方 |
|
||||
| `D:/web/zyt/app/src/doctor_workstation/ui/shell.py:306-423` | 仅当全壳明确纳入验收时调整;可提供 drawer host/overlay host | 不以改壳宽度掩盖诊单页布局问题 |
|
||||
| `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:464-674` | 重建索引层级、筛选、工具栏、表格绑定、页面滚动 | 保留 repository 调用、过滤语义、权限判断、generation、mutation、视频门槛 |
|
||||
| `D:/web/zyt/app/src/doctor_workstation/ui/pages/consultations.py:901-956` | 将普通 loading/error 映射到 table-only overlay/状态层 | 20秒 silent refresh 继续不闪;不改请求时序 |
|
||||
| `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:208-424` | 拆 readonly/drawer 视图,重建 header/tabs/form/cards/tables | 保留 `open_for` 契约、saved signal、隐私可见性和已有数据加载 |
|
||||
| `D:/web/zyt/app/src/doctor_workstation/ui/dialogs/diagnosis.py:426-917` | 把加载/错误/锁定/保存映射到局部视觉状态 | 不改 endpoint、payload、字段校验、generation、防过期响应 |
|
||||
| `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:219-646` | 把预约改成 Drawer、radio/date/slot grid、overlay/empty/alert | 保留排班来源、日期/过期过滤、冲突门槛、submit gate、`payload()` |
|
||||
| `D:/web/zyt/app/src/doctor_workstation/ui/pages/patients.py:2208-2224` | 仅替换打开的视觉容器 | 不改预约保存调用与 mutation reconcile |
|
||||
|
||||
推荐实施顺序:
|
||||
|
||||
1. 建 scoped token 与 drawer/table 基础组件。
|
||||
2. 重建 index 层级、筛选、固定列和 states。
|
||||
3. 拆 readonly 与 edit/viewOnly,先完成壳和病历主 Tab。
|
||||
4. 补当前可达的 notes/orders/histories;数据源不存在的主链保持显式 OPEN。
|
||||
5. 重建 appointment 卡片式选择器。
|
||||
6. 最后做两档截图的字号、间距和颜色微调。
|
||||
|
||||
## 9. 截图验收前置条件
|
||||
|
||||
所有截图固定:Windows 缩放 100%、默认亮色、同一 Qt 字体回退、同一权限集合、相同样本数据、无本地主题覆盖。运行态字体/DPR、tooltip/dropdown 坐标和 scrollbar 像素应记录环境,不应以静态源码推断绝对像素。
|
||||
|
||||
索引样本至少包含:`NEW`、双预约、已完成预约、爽约、未确认、已确认未挂号、处方作废、unserved=`null/2/5/8`、可进入/不可进入视频。
|
||||
|
||||
详情样本至少包含:患者基本信息可编辑/锁定、全部权限/裁减权限、备注含图片和文件、订单有/无数据、挂号/指派有/无数据、加载失败。
|
||||
|
||||
预约样本至少包含:未选医生、无排班、可约、不可约、当天过去时段、选中、今日冲突、其他日期 info、渠道需要/不需要补充、loading/error。
|
||||
|
||||
## 10. 截图验收矩阵
|
||||
|
||||
| ID | 窗口 | 状态 / 操作 | 必须同时看见 | 关键判定 |
|
||||
|---|---|---|---|---|
|
||||
| IDX-01 | 1024×640 | 默认15条、筛选收起、横滚最左 | 无页面内标题;两张平卡;两行筛选;表头、分页 | chips 不裁字;搜索完整;默认当天 active;至少能正常纵向滚整页 |
|
||||
| IDX-02 | 1024×640 | 横滚到最右 | 左表发生横滚;视频120+操作340仍贴右 | 固定区不移动;双表 header/body/row height 无错位 |
|
||||
| IDX-03 | 1024×640 | 更多筛选展开 | 虚线分隔、高级控件可换行、表格仍可达 | 显隐无动画;24/32px混合高度按源码;页面整体可滚 |
|
||||
| IDX-04 | 1024×640 | 四类 chip active + 键盘 focus | 当天/待预约/已完成/待分配各自语义色 | 计数为同行文字;focus清晰且无几何跳动 |
|
||||
| IDX-05 | 1024×640 | 普通、未确认、未挂号 hover | 斑马、#F8F8F8 hover、3px语义条/渐变 | fixed区背景连续;hover不被 selection 色覆盖 |
|
||||
| IDX-06 | 1024×640 | 慢请求 loading | 原表尺寸保持、table-only 半透明层、42px spinner | 筛选/卡位置不跳;无全宽 banner 插入 |
|
||||
| IDX-07 | 1024×640 | 空结果 | 表头、60px“暂无数据”、分页 | 无大圆圈插画;pager不消失 |
|
||||
| IDX-08 | 1440×900 | 默认15条 | 完整两卡、自然表高、完整分页 | 少量数据不拉出大空白;15条由整页滚动承载 |
|
||||
| IDX-09 | 1440×900 | 多预约 + 操作换行 | 预约 badge/渐变/虚线、340px操作链接 | 行高由内容增长;左右固定表同步 |
|
||||
| IDX-10 | 两档 | tooltip / 更多菜单 | 助理溢出、未服务、视频 tooltip;bottom-end菜单 | tooltip只在语义命中区;删除前分隔且danger |
|
||||
| DET-01 | 1024×640 | edit 基本 Tab | 右侧约614px全高 Drawer、header、横滚Tabs、固定footer | body单独滚;footer不裁;label160px;无系统标题栏 |
|
||||
| DET-02 | 1024×640 | viewOnly + 患者锁定 | info badge、disabled表单、warning Alert、关闭按钮 | 无保存;read-only外观不似可编辑;隐私字段无泄漏 |
|
||||
| DET-03 | 1440×900 | edit 全权限 | 右侧约864px、Tab正确顺序、24/12/8栅格 | 无TrackingNote;active底条3px;表单字段顺序与§4.5一致 |
|
||||
| DET-04 | 两档 | 独立 readonly | 无Tabs/模态;返回+Hero+摘要+病例+权限卡片 | 1024可用;1440病例保持4/3列;未服务三色正确 |
|
||||
| DET-05 | 两档 | 详情 loading/error/empty | overlay;Hero下错误Empty;各表局部空态 | 不残留错误seed;error不导致footer/tab位移 |
|
||||
| DET-06 | 1440×900 | 备注/订单/指派/挂号 | 时间轴图片/文件;订单工具栏/详情;完整列/Tag | 表头顺序、状态色、固定动作列与管理端一致 |
|
||||
| APT-01 | 1024×640 | 初始 loading / 未选医生 | 右侧约614px Drawer、body overlay、时间区Empty、固定footer | label100px;footer可见;渠道补充默认正确隐藏 |
|
||||
| APT-02 | 1024×640 | 无排班 | 医生radio、时间区“暂无排班”Empty | 不用ComboBox placeholder冒充空态 |
|
||||
| APT-03 | 1024×640 | 可约/不可约/hover/selected | 日期130×40;slot最小110×70;刷新 | 不可约仍显示但禁用;hover抬升;selected蓝渐变 |
|
||||
| APT-04 | 两档 | 今日冲突 / 其他日期重复 | 顶部warning切info、确认disabled/available | 状态随所选日期切换;不只在点击提交后提示 |
|
||||
| APT-05 | 1440×900 | 完整预约表单 | 右侧约864px、医生和日期自然换行、slot多列 | 不保持540px窄窗;body滚动,footer固定 |
|
||||
| A11Y-01 | 两档 | 仅键盘遍历 | chip/input/select/radio/checkbox/table/button均有focus | 顺序可预测;focus对比足够;无控件尺寸抖动 |
|
||||
|
||||
## 11. 发布门槛
|
||||
|
||||
以下任一项仍存在即不能标记视觉对齐完成:
|
||||
|
||||
- 索引页仍有第二个页面标题,或高级筛选默认常驻。
|
||||
- 表格仍是单个纯文本 `QTableWidget`,右侧 460 px 不固定。
|
||||
- 空态仍替换掉表头/分页,或普通加载仍插入全宽 Banner。
|
||||
- 只读与 viewOnly 仍复用同一个居中 Tabs Dialog。
|
||||
- 编辑/预约仍不是 60% 右贴边、全高、固定 footer 的 Drawer。
|
||||
- 病历仍由一串同形态 `QPlainTextEdit` 组成。
|
||||
- 医生、日期和时段仍以 ComboBox 代替 radio/button/grid。
|
||||
- 1024×640 下任何 footer、搜索、chip 或表格固定区被裁剪。
|
||||
- 1440×900 下预约仍固定 540 px,或详情仍只有固定 880 px 居中模态形态。
|
||||
- hover/focus/loading/empty/error/disabled/selected 任一规定状态没有独立截图证据。
|
||||
|
||||
本报告只定义视觉和交互呈现的修复边界。所有 repository、endpoint、payload、权限 code、状态门槛和 mutation reconcile 均应原样保留,并在实现后另走现有业务回归测试。
|
||||
@@ -0,0 +1,164 @@
|
||||
# 诊单发布后端合同独立终验
|
||||
|
||||
- 终验日期:2026-08-11
|
||||
- 审计对象:`D:/web/zyt/app` 当前工作区终态
|
||||
- 对照基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/`、其直接引用的 API,以及 `D:/web/zyt/server` 当前服务端控制器/Logic
|
||||
- 审计性质:只读业务与后端合同终验;未访问网络、未安装依赖、未调用线上接口;除本报告和 pytest 临时产物外,未修改业务源码或测试
|
||||
|
||||
## 发布结论
|
||||
|
||||
**PASS(限本报告题定范围)。**
|
||||
|
||||
| 等级 | OPEN 数 | 结论 |
|
||||
|---|---:|---|
|
||||
| P0 | **0** | 未发现错误对象写入、越权入口、错误 endpoint、旧异步回调落地或主数据破坏风险 |
|
||||
| P1 | **0** | 题定的所有主流程均已形成真实后端闭环 |
|
||||
| P2 | **0** | 前两轮发现的 Daily 既有记录不可编辑、视频只显示首条 URL/不能逐通话追加等合同缺口均已关闭 |
|
||||
|
||||
**AVAILABLE 后端功能是否全部对齐:是。** 在“admin 诊单页面及其直接组件/API 中、本报告明确列出的可用后端功能”这一范围内,桌面端已经全部对齐:列表查询与分页、权限和菜单、多挂号精确取消、订单创建与付款二维码、三种详情模式、Daily 新增/编辑、真实 Notes 追加与附件、处方/业务订单、视频、归档 IM,以及 Shell owner/异步代次保护均为真实调用,无伪造成功动作。
|
||||
|
||||
这里的 AVAILABLE 不扩张为对 `admin` 全仓所有未枚举页面和无关路由的声明。本报告也不把后端从未提供的功能算成桌面端缺口:**任意旧笔记正文覆盖、删除整条医生笔记,以及旧跟踪备注改写/整条删除,在当前 server 快照中没有 endpoint,属于后端合同限制,不计入 P0/P1/P2。**
|
||||
|
||||
## 合同终验矩阵
|
||||
|
||||
| 题定能力 | 状态 | admin / 后端合同 | 桌面端终态 |
|
||||
|---|---|---|---|
|
||||
| 诊单列表筛选、分页、权限、菜单 | **CLOSED** | `tcm.diagnosis/lists`;canonical 权限;服务端动态菜单 | 筛选字段、待分配宽搜、分页总数/末页回退、统计计数请求、菜单裁剪均对齐 |
|
||||
| 多挂号精确取消 | **CLOSED** | `POST doctor.appointment/cancel {id}` | 每张挂号卡携带精确 appointment id,确认前后重验同一诊单和可取消状态 |
|
||||
| 创建订单后生成付款二维码 | **CLOSED** | `order.order/create` → `order_no` → `tcm.diagnosis/generateOrderQrcode` | 已消费真实 `order_no`,即时请求 QR;缺 order_no / qrcode_url 时 fail-closed,可重试且有 selection/generation 防串单 |
|
||||
| edit / viewOnly / readonly | **CLOSED** | edit、viewOnly 用 `detail`;独立 readonly 用 `readonlyDetail`;保存用 `edit` | mode dispatcher 与 admin 一致,viewOnly 不误打 readonly endpoint |
|
||||
| Daily 新增与既有血糖/饮食/运动编辑 | **CLOSED** | 三类 `add` / `edit` endpoint | 已有单元格解析 record id 并走 update;新增走 add;权限、DTO、owner/generation 校验完整 |
|
||||
| Notes 真实新增与附件 | **CLOSED(AVAILABLE)** | `addDoctorNote`、`doctorNotes`、`deleteDoctorNoteImage` | 正文真实追加;舌象/报告先上传得到服务端 URI 再新增;单附件真实删除 |
|
||||
| 处方与业务订单 | **CLOSED** | `tcm.prescription/add`、`listByDiagnosis`、`tcm.prescriptionOrder/lists` / `detail` 等 | 真实开方、历史处方查看、诊单上下文订单列表/详情与诊次偏移均接通 |
|
||||
| 视频全量回放与逐通话上传 | **CLOSED** | `getCallRecords`、`createManualCallRecord`、`attachLocalCallRecording` | 完整消费 `recording_urls_list`;工具栏上传可建手工记录;行内上传传精确 `call_record_id` |
|
||||
| IM 归档读取与同步 | **CLOSED** | `only_archived=1`、`triggerImChatSync` | 首次及重载均固定归档读取;真实触发同步并按 media generation 刷新 |
|
||||
| owner resize / generation | **CLOSED** | UI 生命周期合同 | 每次打开重绑真实 Shell;overlay 同步 Move/Resize/Show/Close;宽屏 60%、窄屏全宽;关闭使全部异步代次失效 |
|
||||
|
||||
## 逐项证据
|
||||
|
||||
### 1. 诊单列表:筛选、分页、权限与菜单
|
||||
|
||||
- admin 的筛选模型及待分配宽搜在 `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:773-917`;待分配有关键词时只保留分页、`pending_assign=1` 和 `pending_assign_keyword`,没有关键词时带月份。
|
||||
- 桌面端 `src/doctor_workstation/ui/pages/consultations.py:1566-1602` 发送 canonical 字段:`keyword`、`has_appointment`、`diagnosis_confirmed`、`diagnosis_type`、`syndrome_type`、`assistant_id`、最新挂号日期/渠道、最新分配日期、`appointment_date`、`pending_booking`、`completed_appointment`、`pending_assign`、`sort_unserved_days`。待分配宽搜规则与 admin 一致。
|
||||
- `consultations.py:1604-1653` 把 `page/page_size` 传入 repository,消费真实 `total`,超末页时回退并重查;每页仅接受 15/20/30/40。`:1672-1741` 的日期/待挂号/已完成/待分配计数沿用同一筛选语义。
|
||||
- production repository 在 `src/doctor_workstation/services/repository.py:1562-1589` 调用 `GET tcm.diagnosis/lists`,不丢弃当前 canonical 查询字段。
|
||||
- `src/doctor_workstation/ui/shell.py:46-254` 将 `tcm.diagnosis/lists` 作为诊单导航权限,识别 `tcm/diagnosis` 与 `tcm/diagnosis/index`;生产菜单以服务端 visible/enabled/sort/children 为事实源,同时还必须通过精确 slash 权限。空生产菜单不会静默回退出诊单入口,demo 才允许本地回退。
|
||||
- 页面动作同样使用 exact slash 权限并同时检查 repository capability;没有把 dot 形式或相似字符串当成授权。
|
||||
|
||||
结论:查询 DTO、统计 DTO、分页和动态菜单/动作门禁均与 admin 的实际合同对齐。
|
||||
|
||||
### 2. 多挂号精确 `doctor.appointment/cancel`
|
||||
|
||||
- admin 从 `D:/web/zyt/admin/src/api/doctor.ts:60` 调用 `/doctor.appointment/cancel`,诊单列表在 `index.vue:1820`、`:1847` 传具体挂号 `id`。
|
||||
- 桌面端按嵌套挂号数组生成多个操作;歧义的行级“取消挂号”仅在唯一可取消挂号时成立。`consultations.py:2040-2107` 会:
|
||||
1. 按传入的精确 appointment id 查找状态可取消的挂号;
|
||||
2. 绑定当前 diagnosis;
|
||||
3. 用户确认后再次从当前模型按同一 id 重验;
|
||||
4. 最终只把该 id 传给 diagnosis 专用取消方法。
|
||||
- `repository.py:1550-1555` 明确调用 `POST doctor.appointment/cancel {"id": appointment_id}`,没有误走患者工作区的 `firstvisit.myPatient/cancelAppointment`。
|
||||
|
||||
结论:同一诊单有多条挂号时不会取消错行,超 32 位 Python 整数也不会因 UI 转换而截断;本项 CLOSED。
|
||||
|
||||
### 3. 创建订单 → `order_no` → `generateOrderQrcode`
|
||||
|
||||
- admin 的完整链在 `D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:1478-1499`:`orderCreate` 成功后读取 `res.order_no`,随后请求 `generateOrderQrcode({order_no})`。
|
||||
- 桌面端 `consultations.py:2279-2329` 只有在 `tcm.diagnosis/order` 权限以及 create/QR 两个 capability 都存在时开放入口。
|
||||
- `:2331-2479` 已完整串联结果:读取顶层或 `data.order_no`,创建 QR 对话框并立即请求真实 QR;缺少 `order_no` 不会伪造二维码,缺少 `qrcode_url` 显示失败;retry、当前 diagnosis/patient、权限与独立 order generation 均受保护。
|
||||
- `repository.py:2035-2076` 精确调用 `order.order/create`,返回 mapping;再调用 `tcm.diagnosis/generateOrderQrcode {"order_no": ...}`,并强制要求非空 `qrcode_url`。
|
||||
|
||||
结论:此前“订单已创建但付款 QR 闭环中断”的 P1 已关闭。
|
||||
|
||||
### 4. edit、viewOnly、readonly endpoint 分流
|
||||
|
||||
- admin edit 与 viewOnly 共用 `tcmDiagnosisDetail`:`D:/web/zyt/admin/src/views/tcm/diagnosis/edit.vue:1263-1322`;保存调用 `tcmDiagnosisEdit`(`:1367`)。独立 readonly 页调用 `diagnosisReadonlyDetail`:`readonly.vue:155`、`:231`。
|
||||
- API 定义分别是 `GET /tcm.diagnosis/detail`、`POST /tcm.diagnosis/edit`、`GET /tcm.diagnosis/readonlyDetail`:`D:/web/zyt/admin/src/api/tcm.ts:9-11`、`:50-52`、`:60-62`。
|
||||
- 桌面端 mode resolver 在 `src/doctor_workstation/ui/dialogs/diagnosis.py:1427-1544`:edit 与 viewOnly 均调用普通 detail;仅独立 readonly 调用 readonlyDetail。`repository.py:1591-1626` 实现同一分流和 edit 保存。
|
||||
|
||||
结论:viewOnly 不再误用权限感知 readonly endpoint;三种模式 CLOSED。
|
||||
|
||||
### 5. Daily:新增及既有血糖/饮食/运动编辑
|
||||
|
||||
- admin `DailyMatrix.vue:586-648` 按日期聚合记录并保留可编辑 id,`:921-1013` 点击已有单元格时载入记录,提交时按 id 在 add/edit 之间分流。
|
||||
- admin API 的现存合同为:
|
||||
- `tcm.bloodRecord/add` / `edit`:`D:/web/zyt/admin/src/api/tcm.ts:114-120`;
|
||||
- `tcm.dietRecord/add` / `edit`:`:146-152`;
|
||||
- `tcm.exerciseRecord/add` / `edit`:`:173-179`。
|
||||
- 桌面端 Daily matrix 已按同样规则合并同日血糖字段,并给已有血糖、饮食、运动单元格保留 record id;`diagnosis.py:2234-2338` 分别打开新增/编辑器,并按 id 调用 add 或 update。
|
||||
- production repository 在 `repository.py:1758-1818` 精确实现上述六个 endpoint;编辑器保留饮食/运动既有图片,mutation 完成只在 diagnosis/patient 和 daily generation 仍匹配时刷新。
|
||||
|
||||
结论:此前“只能新增、不能编辑已有三类记录”的 P2 已关闭。跟踪备注仍按后端设计只允许当天追加,见后端限制章节。
|
||||
|
||||
### 6. Notes:真实新增、上传与单附件删除
|
||||
|
||||
- admin `D:/web/zyt/admin/src/api/patient.ts:20-40` 只有三条医生笔记 API:`addDoctorNote`、`doctorNotes`、`deleteDoctorNoteImage`。
|
||||
- 桌面端 `diagnosis.py:2424-2511` 的正文新增真实调用 `add_doctor_note`;舌象/报告先调用 multipart `upload_material`,取得服务端 URI 后再把 URI 交给新增接口;单附件删除携带 note id、附件类别和服务器路径。
|
||||
- production repository 在 `repository.py:906-985` 实现上传以及 `doctor.appointment/addDoctorNote`、`doctor.appointment/doctorNotes`、`doctor.appointment/deleteDoctorNoteImage`,并拒绝把本地路径当成已上传附件提交。
|
||||
- 所有按钮同时受 exact permission 和 capability 裁剪,viewOnly/readonly 不出现可写入口。
|
||||
|
||||
结论:当前后端实际 AVAILABLE 的 Notes 能力全部对齐;没有用本地假数据或“只 toast 不落库”模拟新增/附件操作。
|
||||
|
||||
### 7. 处方与诊单上下文订单
|
||||
|
||||
- 详情页处方 Tab 会查询 `tcm.prescription/listByDiagnosis`,编辑态“开方”打开真实处方编辑器并调用 `tcm.prescription/add`;历史处方查看使用该真实列表返回的处方数据打开详情对话框。
|
||||
- 订单 Tab 使用 `tcm.prescriptionOrder/lists` 并带诊单上下文,详情调用 `tcm.prescriptionOrder/detail`;诊次统计偏移调用真实 `setRevisitSlotStartOffset`。
|
||||
- production endpoint 证据:`repository.py:1131-1149`、`:1220-1265`、`:1633-1645`;UI 调度与 capability 门禁在 `diagnosis.py:2048-2058`、`:2534-2689`、`:2791-3186`。
|
||||
|
||||
结论:题定的处方创建/查看及业务订单列表/详情/偏移均为真实后端行为,未发现伪动作或无权限仍可提交。
|
||||
|
||||
### 8. 视频:全量 `recording_urls_list` 与逐 `call_record_id` 上传
|
||||
|
||||
- admin `CallRecordPanel.vue:32` 把完整 `row.recording_urls_list` 传给播放组件;`:150-177` 支持“上传后新建手工通话记录再绑定”,也支持向指定现有记录追加。
|
||||
- 桌面端 `diagnosis.py:2899-2967` 会归一化并去重 direct URL 与完整 `recording_urls_list`,逐一提供播放入口,不再只取数组第一个元素。每个有正 id 的行提供“追加回放”,闭包携带该行精确 `call_record_id`。
|
||||
- `diagnosis.py:2587-2613` 将可选精确 id 交给 repository。`repository.py:1875-1943` 的顺序与 admin 一致:先上传;无 id 时创建 manual call record;最后调用 `attachLocalCallRecording`。已有 id 时不创建新记录,直接绑定该 id。
|
||||
|
||||
结论:此前“只展示首条 URL、不能逐通话记录追加”的两个 P2 缺口均已关闭。
|
||||
|
||||
### 9. IM:`only_archived` 与 sync
|
||||
|
||||
- admin `ImChatRecordPanel.vue:148-185` 固定 `only_archived: 1` 并调用 `triggerImChatSync`。
|
||||
- 桌面端 lazy query 在 `diagnosis.py:2084-2097` 固定 `only_archived=True`,同步动作在 `:2615-2627` 调用真实 repository,并在当前 media generation 仍有效时重载。
|
||||
- `repository.py:1945-1965` 精确发送 `GET tcm.diagnosis/getImChatMessages {diagnosis_id, only_archived: 1}` 与 `POST tcm.diagnosis/triggerImChatSync`。
|
||||
|
||||
结论:未混入 live merge 默认值,归档读取和同步闭环 CLOSED。
|
||||
|
||||
### 10. Shell owner、resize 与 generation
|
||||
|
||||
- `diagnosis.py:1325-1413` 每次打开/显示都从当前 `parentWidget().window()` 重绑真实 owner;overlay 采用 owner 的全局原点与完整尺寸,drawer 在宽屏取 owner 60%,窄屏取全宽,并响应 owner Move/Resize/Show/Close。
|
||||
- `diagnosis.py:1731-1760` 在 reject/done/owner close 时统一递增 detail、save、orders、order detail、daily、notes、media、offset 及所有 lazy-tab generation,并关闭相关子对话框/播放器。
|
||||
|
||||
结论:移动或缩放真实 Shell 后几何会立即同步;关闭后旧请求结果不能回写到下一次打开的诊单。
|
||||
|
||||
## 后端合同限制:旧笔记不能任意覆盖或整条删除
|
||||
|
||||
本项进行了 admin API、controller、Logic 和 `D:/web/zyt/server` 全仓关键词交叉检索,当前 server 快照的结论明确:
|
||||
|
||||
1. `D:/web/zyt/server/app/adminapi/controller/doctor/AppointmentController.php:168-198` 仅公开 `addDoctorNote`、`doctorNotes`、`deleteDoctorNoteImage`,随后该 controller 结束;不存在医生笔记正文 edit/update 或整条 delete action。
|
||||
2. `D:/web/zyt/server/app/adminapi/logic/doctor/DoctorNoteLogic.php:14-76` 的 `addOrAppend` 按 `diagnosis_id + 当天` find-or-create,并把新正文追加到当日内容,不允许任意覆盖历史正文。
|
||||
3. 同一 Logic 的唯一删除逻辑 `deleteImage`(`:114-136`)只从 `tongue_images` 或 `report_files` 数组中删除一个附件,不删除笔记行。
|
||||
4. admin API `D:/web/zyt/admin/src/api/patient.ts:20-40` 也只有上述三条接口,没有被桌面端漏接的隐藏 edit/delete API。
|
||||
5. 跟踪备注同样只有 `addTrackingNote` 与 `trackingNotes` controller action(`DiagnosisController.php:183-214`);`TrackingNoteLogic.php:23-95` 只有按当天追加和按诊单读取,没有历史正文覆盖或整条删除。
|
||||
|
||||
因此:
|
||||
|
||||
- “任意覆盖既有医生/跟踪笔记正文”与“删除整条笔记”是**后端合同限制**,不是客户端 P0/P1/P2。
|
||||
- 当前桌面端不展示无法兑现的按钮是正确的 fail-closed 行为。
|
||||
- 若产品必须新增这些能力,应先在 server 增加独立 authenticated endpoint、权限码、归属校验、审计记录、并发/版本策略和删除语义;在此之前不应由客户端通过追加接口模拟覆盖或删除。
|
||||
|
||||
## 离线验证
|
||||
|
||||
在当前 `.venv` 下使用 `QT_QPA_PLATFORM=offscreen`、`PYTHONDONTWRITEBYTECODE=1`,禁用 pytest cache,并把 basetemp 放到 `artifacts/pytest_release_contract_audit/`;未使用网络:
|
||||
|
||||
```text
|
||||
python -m pytest -q -p no:cacheprovider --basetemp artifacts/pytest_release_contract_audit tests/test_diagnosis_detail_contract.py tests/test_consultations_parity_ui.py tests/test_diagnosis_drawer_visual.py tests/test_diagnosis_index_visual.py tests/test_shell_contract.py tests/test_repository_parity.py tests/test_permissions.py
|
||||
|
||||
118 passed
|
||||
```
|
||||
|
||||
退出码为 0。该结果覆盖 endpoint/DTO、权限裁剪、多挂号精确取消、订单 QR 串联、三种详情模式、Daily edit、Notes、视频多 URL/逐 call id、IM 和 owner/generation 的定向回归。测试通过是回归信号;最终 AVAILABLE 判定同时依赖上面的 admin/API/server 静态合同核对,不以 mock 测试单独代替生产合同证据。
|
||||
|
||||
## 发布门槛判断
|
||||
|
||||
- **业务/后端合同门槛:通过。**
|
||||
- **OPEN:P0 0 / P1 0 / P2 0。**
|
||||
- **AVAILABLE:题定范围内全部对齐。**
|
||||
- **唯一保留项:旧医生/跟踪笔记正文覆盖与整条删除需先扩展后端合同;这是明确的 server limitation,不阻断“当前 AVAILABLE 功能”发布。**
|
||||
@@ -0,0 +1,173 @@
|
||||
# Diagnosis 发布视觉终验
|
||||
|
||||
审计日期:2026-08-11
|
||||
审计性质:独立只读终验;除新增本报告外,未修改业务源码、测试、renderer 或 PNG。
|
||||
基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/` 及其当前主链直接依赖组件。
|
||||
当前实现:PySide6 + scoped QSS + QPainter,主要位于 `src/doctor_workstation/ui/`。
|
||||
Trellis:`D:/web/zyt/app/.trellis` 当前不存在(`Test-Path` 为 `False`),因此无可补读的 Trellis workflow/spec。
|
||||
|
||||
## 最终结论
|
||||
|
||||
**PARTIAL,不是 PASS。**
|
||||
|
||||
- 检查了 `artifacts/diagnosis_visual/` 当前全部 **49 张 PNG**,均以原始尺寸逐张目检;尺寸分布为:`1024x640` 22 张、`1280x800` 11 张、`1440x900` 12 张、`640x540` / `650x620` / `820x560` / `920x780` 各 1 张。
|
||||
- PNG 判定:**40 PASS / 9 PARTIAL / 0 不可读 / 0 硬裁切**。
|
||||
- **P0:0 OPEN。** 诊单列表、右固定 460 px 区、付款二维码、edit/viewOnly 的 60% Drawer、独立 readonly、预约 Drawer、固定 footer、loading/error/empty/focus/permission 状态及 Shell 183/50/40 均未发现发布阻断。
|
||||
- 上一轮明确 OPEN 中,Daily 动作行裁切与按钮语义、完整更多菜单的图标/danger/“二维码”文案已经闭合;视频的多回放、逐条追加与应用内播放器能力也已经存在。
|
||||
- 仍不能给 PASS 的原因是 4 组 P1 同型差异:Notes 不渲染真实缩略图;处方编辑器仍是另一种 tabbed modal;订单详情仍是精简 640x540 表单而非共享 80% Drawer;视频仍从表格跳到独立播放器且聊天图片仍降级为文字链接。另有 2 组 P2 文案/局部工具栏差异。
|
||||
|
||||
严重度口径:P0 = 核心流程不可见/不可操作或窗口级裁切;P1 = 本次明确要求的主链组件结构、信息层级或媒体形态未与基准同型;P2 = 功能成立但文案、局部按钮或说明层级未精确对齐。
|
||||
|
||||
## 此前 OPEN 复核
|
||||
|
||||
| 项目 | 当前状态 | 精确证据 |
|
||||
|---|---|---|
|
||||
| Daily 裁切 | **CLOSED** | `diagnosis_state_daily_1024x640.png` 中“刷新”已换到下一行且完整可见;当前用 `FlowLayout`:`src/doctor_workstation/ui/diagnosis_drawer.py:1607-1609`,基准允许换行:`D:/web/zyt/admin/src/views/tcm/diagnosis/components/DailyMatrix.vue:1094-1109`。 |
|
||||
| Daily 按钮语义 | **CLOSED(主问题)** | `+血糖` 为 primary,`+饮食/+运动/+备注` 为 secondary,范围 checked 态可见,`新增待办` 为 primary;实现:`diagnosis_drawer.py:1613-1659,1691-1702`;基准:`DailyMatrix.vue:25-35`、`DiagnosisTodoList.vue:3-19`。仍有下述 P2 文案/局部刷新差异。 |
|
||||
| 完整更多菜单 | **CLOSED** | `diagnosis_full_menu_1280x800.png` 显示 8 项、每项图标、删除分隔线与 danger 红、精确“二维码”。QPainter 图标与 danger 绘制:`src/doctor_workstation/ui/diagnosis_index_widgets.py:74-175`;动作与文案:`:1478-1556`;基准:`D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:379-400`。 |
|
||||
| Notes 动作与附件 | **PARTIAL** | 新增/追加、舌象/报告上传、打开与单附件删除均已出现;但舌象始终是 64x64 的“舌象/查看”文字按钮(`diagnosis_drawer.py:2153-2183`),没有像基准那样用真实 URL 渲染 `el-image` 缩略图(`D:/web/zyt/admin/src/views/patient/reception/components/NoteTimeline.vue:52-69,370-379`)。 |
|
||||
| 处方入口 | **CLOSED** | `diagnosis_state_empty_1024x640.png` 中按钮已经是 primary “开方”;实现:`src/doctor_workstation/ui/dialogs/diagnosis.py:1012-1031`;基准:`CaseRecordList.vue:1-38`。编辑器结构仍有 P1,见下节。 |
|
||||
| 订单列表/偏移/详情 | **PARTIAL** | 列表、金额色、状态 tag、分页、详情入口均存在(`diagnosis.py:1056-1099,2980-3010`),并补了详情 PNG;但详情结构仍非基准共享 Drawer,偏移文案也未精确。 |
|
||||
| 视频多回放 | **CLOSED(能力)/ PARTIAL(同型)** | `diagnosis_state_video_replay_1024x640.png` 同一记录显示主回放 + 2 个备用回放;实现去重并逐条建链接:`diagnosis.py:2899-2952`。 |
|
||||
| 视频逐条上传 | **CLOSED** | `diagnosis_state_video_upload_action_1024x640.png` 显示行级“追加回放”;绑定精确 `call_record_id`:`diagnosis.py:2953-2967`,上传调用:`:2587-2613`。 |
|
||||
| 视频播放器 | **CLOSED(能力)/ PARTIAL(同型)** | `diagnosis_state_video_player_820x560.png` 显示应用内播放面、播放/进度/时间/安全外开;实现:`src/doctor_workstation/ui/diagnosis_media.py:38-172`。但基准播放器内嵌在表格单元格且最大高 180 px:`RecordingPlaybackBlock.vue:1-30`、`RecordingVideoPlayer.vue:1-38,320-329`。 |
|
||||
| Chat info/归档 | **CLOSED(主体)/ PARTIAL(图片)** | `diagnosis_state_chat_archive_1024x640.png` 已有完整 info、only_archived、同步/重载与左右气泡;实现:`diagnosis_drawer.py:2235-2360`。图片消息仍只创建“查看图片”链接(`:2329-2348`),基准直接渲染图片缩略图(`ImChatRecordPanel.vue:39-50,272-276`)。 |
|
||||
| Shell | **CLOSED** | 两张 Shell PNG 均为 183/50/40;当前源码:`src/doctor_workstation/ui/shell.py:540,602,686`;基准:`D:/web/zyt/admin/src/config/setting.ts:5`、`D:/web/zyt/admin/src/styles/var.css:8`、`D:/web/zyt/admin/src/layout/default/components/header/multiple-tabs.vue:65`。 |
|
||||
|
||||
## P0 / P1 / P2
|
||||
|
||||
### P0
|
||||
|
||||
**0 项。** 49 张图未发现核心信息被窗口边界硬裁、60% Drawer 尺寸失效、footer 随正文滚走、中文/图标缺字、二维码不可辨识或 1024/1440 壳层错位。
|
||||
|
||||
### P1-1 Notes 舌象不是实际缩略图
|
||||
|
||||
- 实图:`diagnosis_state_notes_actions_1024x640.png` 中舌象为蓝色文字卡“舌象 / 查看”,不是图像;报告文件 chip 和两个删除入口可见。
|
||||
- 当前源码明确只创建 `QPushButton("舌象\n查看")`,没有加载 URL 到 pixmap:`src/doctor_workstation/ui/diagnosis_drawer.py:2153-2183`。
|
||||
- 基准用 `el-image :src="getImageUrl(url)"`、64x64 cover 缩略图与叠加 CircleClose:`D:/web/zyt/admin/src/views/patient/reception/components/NoteTimeline.vue:52-69,366-379`。
|
||||
- 建议边界:保留当前安全打开与单附件删除合同,只把可安全下载/解码的图片异步渲染为 64x64 缩略图;失败时再回退为当前文字卡。
|
||||
|
||||
### P1-2 处方编辑器不是基准的 1200 px 连续 Drawer
|
||||
|
||||
- 实图:`diagnosis_state_prescription_editor_920x780.png` 是 920x780 modal,按“患者与诊断 / 药材配方 / 剂型与用法 / 医师签名”拆为 4 个 tabs。
|
||||
- 当前结构:`src/doctor_workstation/ui/dialogs/prescription.py:1017-1037`;诊单入口调用该 dialog:`src/doctor_workstation/ui/dialogs/diagnosis.py:2533-2585`。
|
||||
- 基准入口直接打开 `tcm-prescription`,其根是 `size="1200px"` Drawer,并在同一滚动流中依次呈现患者信息、诊断信息、RP 药材工具栏/网格、用法和签名:`D:/web/zyt/admin/src/views/tcm/diagnosis/index.vue:416,744`;`D:/web/zyt/admin/src/components/tcm-prescription/index.vue:2-216,217-531`。
|
||||
- 当前 DTO/真实提交能力存在,本项是视觉结构 P1,不是功能缺失。
|
||||
|
||||
### P1-3 订单详情仍是精简 modal
|
||||
|
||||
- 实图:`diagnosis_state_order_detail_640x540.png` 只有 hero、状态 tag 和 10 行键值信息。
|
||||
- 当前源码固定 `640x540`,用单个 `QFormLayout` 罗列患者、金额、医护、地址、物流、备注:`src/doctor_workstation/ui/dialogs/diagnosis.py:3060-3135`。
|
||||
- 基准 `PatientOrderList` 打开共享 readonly `PrescriptionOrderDetailDrawer`:`D:/web/zyt/admin/src/views/tcm/diagnosis/components/PatientOrderList.vue:96-124`;共享组件宽 `80%`,readonly 仍保留金额概览、处方详情、未关联/关联收款、履约收货、物流轨迹与操作日志:`D:/web/zyt/admin/src/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue:13-16,154-197,200-545,551-824`。
|
||||
- 因结构和信息密度均明显不同,新增截图只能证明“有详情”,不能证明“同型”。
|
||||
|
||||
### P1-4 视频与聊天媒体形态仍非基准同型
|
||||
|
||||
- 视频:多地址、逐条追加和 QtMultimedia 播放能力均真实存在,但表格只显示文字链接,点击后打开 820x560 独立播放器(`diagnosis.py:2934-2978`;`diagnosis_media.py:38-116`)。基准在“录制回放”单元格内直接嵌入最大高 180 px 的播放器,并把其他地址放在其下:`CallRecordPanel.vue:17-34`、`RecordingPlaybackBlock.vue:1-30`、`RecordingVideoPlayer.vue:320-329`。
|
||||
- 聊天:文字/文件气泡和 info alert 已同型;图片仍为“查看图片”文字链接(`diagnosis_drawer.py:2329-2348`),基准为最大 `240x200` 的可预览图片(`ImChatRecordPanel.vue:39-50,272-276`)。
|
||||
- 两者均可安全使用,但尚未达到这轮明确要求的媒体视觉同型。
|
||||
|
||||
### P2-1 Daily 待办局部工具栏文案仍未精确
|
||||
|
||||
- `diagnosis_state_daily_lower_1024x640.png` 的 primary 按钮为“新增待办”,基准为“+ 新增待办”;当前待办卡内也没有基准同一工具栏上的局部“刷新”(全 Daily 顶部已有刷新)。
|
||||
- 当前:`diagnosis_drawer.py:1691-1702`;基准:`DiagnosisTodoList.vue:3-19`。
|
||||
- 这不再造成裁切或语义压平,故降为 P2。
|
||||
|
||||
### P2-2 订单偏移栏文案与解释层级未精确
|
||||
|
||||
- `diagnosis_state_order_offset_1024x640.png` 显示“全局就诊序号偏移 / 0 / 个序号 / 保存偏移”。
|
||||
- 基准是“复诊统计起始偏移”+ tooltip +“第 1 笔实单计为…”+“保存”:`PatientOrderList.vue:7-41`。
|
||||
- 当前:`src/doctor_workstation/ui/dialogs/diagnosis.py:1056-1083`。数值范围与保存入口成立,本项为文案/说明层级 P2。
|
||||
|
||||
## 尺寸与壳层精确证据
|
||||
|
||||
| 核对项 | 1024x640 | 1440x900 | 源码/基准 |
|
||||
|---|---|---|---|
|
||||
| edit/viewOnly Drawer 60% | panel 从 x=410 开始,宽 614;x=409 仍为 overlay | panel 从 x=576 开始,宽 864 | `diagnosis.py:1336-1340`;基准 `edit.vue:3-9` |
|
||||
| appointment Drawer 60% | panel 从 x=410 开始,宽 614 | panel 从 x=576 开始,宽 864 | `appointment_drawer.py:1716-1719`;基准 `appointment.vue:2-6` |
|
||||
| Shell sidebar | x=0..182 为侧栏,x=183 进入内容;抽样 x182=`#25292D`、x183=`#F6F6F6` | 同一 183 px 边界 | `shell.py:540-543`;基准 `setting.ts:5` |
|
||||
| Shell topbar/tabs | x=200 处 y49 为 topbar 底边,y50 进入 tabs;y89 为 tabs 底边,y90 进入内容,即 50 + 40 | 同一分段 | `shell.py:602,686`;基准 `var.css:8`、`multiple-tabs.vue:65` |
|
||||
|
||||
## 49 张 PNG 逐张判定
|
||||
|
||||
下列 PASS 只证明该张图实际入镜内容;不会拿截图外的滚动内容代替证据。
|
||||
|
||||
### 诊单列表与二维码(14)
|
||||
|
||||
| PNG | 判定 | 目检结论 |
|
||||
|---|---|---|
|
||||
| `diagnosis_1024x640.png` | PASS | 窄屏筛选换行、内部横向溢出、右固定区与行语义完整,无硬裁切。 |
|
||||
| `diagnosis_1440x900.png` | PASS | 宽屏列密度、挂号复合单元格、斑马/业务色与固定操作区成立。 |
|
||||
| `diagnosis_advanced_filters_1280x800.png` | PASS | 高级筛选完整展开,日期范围、控件顺序与重置可见。 |
|
||||
| `diagnosis_double_appointment_cancel_1280x800.png` | PASS | 同诊单两条挂号各有取消入口,虚线层级正常。 |
|
||||
| `diagnosis_empty_1280x800.png` | PASS | 表头、内部空态、横向滚动与 0 条分页均保留。 |
|
||||
| `diagnosis_error_1280x800.png` | PASS | 错误态居中可读,无旧数据穿透。 |
|
||||
| `diagnosis_focus_1280x800.png` | PASS | 搜索框 focus ring 清楚且不挤压布局。 |
|
||||
| `diagnosis_full_menu_1280x800.png` | PASS | 8 项、8 个图标、删除分隔/danger 与精确“二维码”全部可见。 |
|
||||
| `diagnosis_horizontal_scroll_1024x640.png` | PASS | 主表横移时右 460 px 固定区不动,阴影连续。 |
|
||||
| `diagnosis_hover_warning_1280x800.png` | PASS | warning 行 hover 渐变、3 px 左条和固定区同步。 |
|
||||
| `diagnosis_loading_1280x800.png` | PASS | 表内真实圆弧 spinner,内容淡化,无字符 spinner。 |
|
||||
| `diagnosis_pending_assign_1280x800.png` | PASS | 待分配 chip、月份和宽搜索在同一流式区。 |
|
||||
| `diagnosis_permissions_cropped_1280x800.png` | PASS | 权限裁掉批量工具栏和敏感动作,仅留查看。 |
|
||||
| `diagnosis_order_qrcode_1280x800.png` | PASS | 订单号、可辨识 QR、成功态、只读 URL、打开/重试/关闭均完整。 |
|
||||
|
||||
### edit / viewOnly / readonly(6)
|
||||
|
||||
| PNG | 判定 | 目检结论 |
|
||||
|---|---|---|
|
||||
| `diagnosis_edit_1024x640.png` | PASS | 60% Drawer、2/3 列语义表单、横向 tabs、独立滚动体与固定 footer 成立。 |
|
||||
| `diagnosis_edit_1440x900.png` | PASS | 864 px Drawer、宽屏字段比例和 footer 稳定。 |
|
||||
| `diagnosis_viewonly_1024x640.png` | PASS | 60% 只读 Drawer、待配药警告、disabled 表单与仅关闭 footer 成立。 |
|
||||
| `diagnosis_viewonly_1440x900.png` | PASS | 宽屏只读栅格、全 tabs 与固定 footer 成立。 |
|
||||
| `diagnosis_readonly_1024x640.png` | PASS | 独立只读页、hero、病例卡和“客服:赵医助”不拆字。 |
|
||||
| `diagnosis_readonly_1440x900.png` | PASS | 4 列病例密度、异常指标层级及长页面滚动成立。 |
|
||||
|
||||
### 详情状态与子流程(19)
|
||||
|
||||
| PNG | 判定 | 目检结论 |
|
||||
|---|---|---|
|
||||
| `diagnosis_state_loading_1024x640.png` | PASS | 圆弧 spinner 与“正在加载诊单...”完整,无旧 seed 穿透。 |
|
||||
| `diagnosis_state_error_1024x640.png` | PASS | danger banner、重试、空表单和禁用保存完整。 |
|
||||
| `diagnosis_state_empty_1024x640.png` | PASS | 处方空态与 primary “开方”已对齐;编辑器另见 PARTIAL。 |
|
||||
| `diagnosis_state_permission_1024x640.png` | PASS | 仅保留获权 tabs,手机号/身份证掩码,footer 仅关闭。 |
|
||||
| `diagnosis_state_daily_1024x640.png` | PASS | Daily 动作全部可见;刷新换行而未裁切,range/primary/secondary 层级清楚。 |
|
||||
| `diagnosis_state_daily_lower_1024x640.png` | PARTIAL(P2) | 趋势、待办 filters、状态 pill 与 primary 新增成立;缺“+”及待办局部刷新。 |
|
||||
| `diagnosis_state_daily_blood_edit_650x620.png` | PASS | 血糖/血压编辑字段、单位、必填说明、备注和固定动作完整。 |
|
||||
| `diagnosis_state_focus_1024x640.png` | PASS | 姓名 focus ring、紧凑 close 和固定 footer 清楚。 |
|
||||
| `diagnosis_state_save_loading_1024x640.png` | PASS | 按钮内圆弧 spinner 与“正在保存”完整。 |
|
||||
| `diagnosis_state_save_success_1024x640.png` | PASS | 绿色勾与“保存成功”完整。 |
|
||||
| `diagnosis_state_save_failure_1024x640.png` | PASS | 红色叉与“保存失败”完整。 |
|
||||
| `diagnosis_state_notes_actions_1024x640.png` | PARTIAL(P1) | 动作、chip 和单附件删除存在;舌象仍为文字按钮而非真实缩略图。 |
|
||||
| `diagnosis_state_prescription_editor_920x780.png` | PARTIAL(P1) | 完整可用的 4-tab 编辑器已入镜;但不是基准 1200 px 连续 Drawer。 |
|
||||
| `diagnosis_state_order_offset_1024x640.png` | PARTIAL(P2) | 数值、保存、订单表与分页成立;偏移文案/tooltip/解释层级未精确。 |
|
||||
| `diagnosis_state_order_detail_640x540.png` | PARTIAL(P1) | 详情 modal 可用;缺基准 80% Drawer 的金额/支付/履约/物流/日志结构。 |
|
||||
| `diagnosis_state_video_replay_1024x640.png` | PARTIAL(P1) | 主回放 + 2 个备用回放成立;仍是链接列表,不是表内播放器。 |
|
||||
| `diagnosis_state_video_upload_action_1024x640.png` | PARTIAL(P1) | 指定行“追加回放”成立;媒体单元格结构仍未同型。 |
|
||||
| `diagnosis_state_video_player_820x560.png` | PARTIAL(P1) | 应用内播放器和安全外开成立;形态是独立 dialog,不是 180 px 表内播放器。 |
|
||||
| `diagnosis_state_chat_archive_1024x640.png` | PARTIAL(P1) | 完整 info、归档、同步/重载及左右气泡成立;图片消息退化为文字链接。 |
|
||||
|
||||
### 预约 Drawer(8)
|
||||
|
||||
| PNG | 判定 | 目检结论 |
|
||||
|---|---|---|
|
||||
| `appointment_drawer_1024x640.png` | PASS | 60% Drawer、body 滚动、时段状态和固定 footer 无硬裁切。 |
|
||||
| `appointment_drawer_1440x900.png` | PASS | 医生 radio、四个日期按钮、时段网格、备注和 footer 对齐。 |
|
||||
| `appointment_drawer_empty_doctors_1440x900.png` | PASS | warning、空态插图、暂无医生与禁用确认完整。 |
|
||||
| `appointment_drawer_empty_roster_1440x900.png` | PASS | 无排班 warning、空态和“该医生暂无排班”可读。 |
|
||||
| `appointment_drawer_error_1440x900.png` | PASS | 号源错误 banner、刷新入口和空态并存。 |
|
||||
| `appointment_drawer_keyboard_focus_1440x900.png` | PASS | 日期按钮蓝色键盘 focus 边界清楚。 |
|
||||
| `appointment_drawer_loading_1440x900.png` | PASS | 初始加载 overlay、圆弧 spinner、文案与禁用确认完整。 |
|
||||
| `appointment_drawer_refreshing_1440x900.png` | PASS | 刷新 overlay、圆弧 spinner、文案与禁用确认完整。 |
|
||||
|
||||
### Shell(2)
|
||||
|
||||
| PNG | 判定 | 目检结论 |
|
||||
|---|---|---|
|
||||
| `diagnosis_shell_1024x640.png` | PASS | 183/50/40、深色侧栏、tabs、固定列与分页均成立;无 glyph 方框。 |
|
||||
| `diagnosis_shell_1440x900.png` | PASS | 同一壳层比例在宽屏保持,箭头/刷新/全屏/关闭图形和中文完整。 |
|
||||
|
||||
## 发布判定
|
||||
|
||||
- **可以确认无 P0,且此前 Daily、更多菜单、Shell 的明确 OPEN 已关闭。**
|
||||
- **不能给视觉同型 PASS。** 需至少关闭 P1-1 至 P1-4,并补录对应终态 PNG;P2 可在同一轮顺手精确。
|
||||
- 本轮最终:**PARTIAL(40 PASS / 9 PARTIAL,共检查 49 张 PNG)**。
|
||||
@@ -0,0 +1,329 @@
|
||||
# DoctorWorkstation 发布打包就绪审计
|
||||
|
||||
审计日期:2026-08-11(Asia/Shanghai)
|
||||
审计主机:Windows 10/11 工作区 `D:\web\zyt\app`
|
||||
审计边界:只检查入口、脚本、PyInstaller spec、冻结依赖、伴随页、文档和既有产物;未执行完整 PyInstaller,未改业务/打包源码。
|
||||
|
||||
## 结论
|
||||
|
||||
**当前状态:不可直接发布既有 `dist`,也不应立即从当前 Git 状态制作正式发布包。**
|
||||
|
||||
构建流水线的主体是完整的:Windows/macOS 均有根目录中英文入口,能从锁文件准备 Python/Node 依赖,能构建 TRTC Web companion,PyInstaller 使用 onedir/.app,构建后会检查 QtWebEngine 与 companion,并在隔离目录中运行冻结入口 `--smoke-test`。Windows 既有 ZIP 的命名和 SHA-256 也正确。
|
||||
|
||||
但发布前有三个 P0:macOS 入口在 Git 中没有可执行位;新增 QtMultimedia 业务文件仍未纳入版本控制且工作树存在大量未提交发布输入;当前 Windows 成品早于新增媒体代码,缺少 QtMultimedia Python 模块、Widgets 库及媒体插件。另有 P1:当前冻结烟测会在媒体能力缺失时仍通过,生产级签名/公证也尚未进入一键流水线。
|
||||
|
||||
**macOS 特别说明:PyInstaller 不能从 Windows 交叉产出或验收 `.app`。本审计只能检查 macOS 脚本和合同;最终 `.app`、原生 Qt/媒体插件、签名、摄像头/麦克风权限及 TRTC 实通必须在原生 macOS(对应 arm64 或 x86_64)上构建和验证。**
|
||||
|
||||
严重度定义:
|
||||
|
||||
- P0:在任何正式构建/分发前必须解决。
|
||||
- P1:可做内部技术构建,但生产发布前必须解决或由发布负责人书面接受风险。
|
||||
- P2:不阻断本次内部构建,建议纳入发布工程改进。
|
||||
|
||||
## 构建前阻断
|
||||
|
||||
### P0-1:macOS Finder/命令行入口在 Git 索引中全部为 `100644`
|
||||
|
||||
`git ls-files --stage -- '*.command' 'scripts/*.sh'` 显示以下文件均为 `100644`,不是 `100755`:
|
||||
|
||||
- `run_macos.command`
|
||||
- `package_macos.command`
|
||||
- `一键运行.command`
|
||||
- `一键打包.command`
|
||||
- `scripts/build_macos.sh`
|
||||
- `scripts/package_macos.sh`
|
||||
- `scripts/run_macos.sh`
|
||||
- `scripts/macos_helpers.sh`
|
||||
- `scripts/check_macos_entrypoints.sh`
|
||||
|
||||
影响:新的 macOS clone 中,Finder 双击 `.command` 和 README 中的 `./scripts/build_macos.sh` 不能满足“一键运行/一键打包”;`scripts/check_macos_entrypoints.sh` 自己也要求这些入口 `-x`。Windows/NTFS 下 Git Bash 把文件视为可执行,因此当前合同检查通过并不能覆盖 Git 模式错误。
|
||||
|
||||
发布前处理:
|
||||
|
||||
```powershell
|
||||
git update-index --chmod=+x run_macos.command package_macos.command `
|
||||
"一键运行.command" "一键打包.command" `
|
||||
scripts/build_macos.sh scripts/package_macos.sh scripts/run_macos.sh `
|
||||
scripts/macos_helpers.sh scripts/check_macos_entrypoints.sh
|
||||
git ls-files --stage -- '*.command' 'scripts/*.sh'
|
||||
```
|
||||
|
||||
第二条命令的每一行都必须以 `100755` 开头,并将模式变化提交到发布 commit。
|
||||
|
||||
### P0-2:当前发布输入不在可复现的 Git 状态
|
||||
|
||||
审计时工作树含多处已修改和大量未跟踪代码;与本次媒体发布直接相关的 `src/doctor_workstation/ui/diagnosis_media.py` 及对应测试仍是未跟踪文件。若从当前目录构建,PyInstaller 会把这些本地文件打入包;若从当前 HEAD/干净 CI clone 构建,则不会包含它们。两者结果不一致。
|
||||
|
||||
发布前必须由负责人审阅 `git status --short`,只纳入确认属于本次版本的源码、测试、脚本和文档,并从确定的 commit/tag 构建。不要用 `git add -A` 掩盖当前大量测试产物和临时目录。
|
||||
|
||||
### P0-3:既有 Windows ZIP/onedir 早于新增 QtMultimedia 代码,禁止分发
|
||||
|
||||
证据:
|
||||
|
||||
- `dist/DoctorWorkstation/DoctorWorkstation.exe`:2026-08-10 17:07。
|
||||
- `dist/DoctorWorkstation-Windows-x64-0.1.0.zip`:2026-08-10 17:08。
|
||||
- `src/doctor_workstation/ui/diagnosis_media.py`:2026-08-11 01:05。
|
||||
- 既有成品只有 `Qt6Multimedia.dll`/QML 辅助文件,缺少:
|
||||
- `PySide6/QtMultimedia.pyd`
|
||||
- `PySide6/QtMultimediaWidgets.pyd`
|
||||
- `PySide6/Qt6MultimediaWidgets.dll`
|
||||
- `PySide6/plugins/multimedia/ffmpegmediaplugin.dll`
|
||||
- `PySide6/plugins/multimedia/windowsmediaplugin.dll`
|
||||
|
||||
既有 ZIP 也缺少同一组文件。它的 SHA-256 文件本身有效,但只证明旧 ZIP 未损坏,不证明它对应当前源码。
|
||||
|
||||
此外,根目录“一键运行”优先启动既有 `dist`,所以当前直接双击会运行旧代码,不会验证当前工作树。正式验收前必须完成干净重建,并以新 ZIP 的时间戳、文件清单和哈希替换旧证据。
|
||||
|
||||
## P1:生产发布前必须闭环
|
||||
|
||||
### P1-1:QtMultimedia 依赖可用,但冻结收集没有独立门禁
|
||||
|
||||
通过项:
|
||||
|
||||
- `pyproject.toml` 依赖 `PySide6>=6.8.2,<7`;`uv.lock` 当前锁定 PySide6/PySide6 Addons/Essentials 6.11.1,Addons 提供 QtMultimedia。
|
||||
- 当前 `.venv` 和 `.venv-build` 均有 `QtMultimedia.pyd`、`QtMultimediaWidgets.pyd`、`Qt6MultimediaWidgets.dll` 以及 Windows `plugins/multimedia` 下的 FFmpeg/Windows Media 插件。
|
||||
- 当前 PyInstaller 6.22 的官方 `hook-PySide6.QtMultimedia.py` 会追加 `PySide6.QtMultimediaWidgets`,Qt 模块映射会收集 Qt 6 的 `multimedia` 插件。当前源码中的静态 import 理论上会触发该 hook。
|
||||
|
||||
缺口:
|
||||
|
||||
- `packaging/doctor_workstation.spec` 的显式 hidden imports 只有 QtWebEngine 相关模块,没有显式列出 `PySide6.QtMultimedia`/`PySide6.QtMultimediaWidgets`。
|
||||
- Windows/macOS 构建脚本只检查 QtWebEngine helper、Chromium `.pak` 和 `video_companion_dist/index.html`,不检查 QtMultimedia Python 扩展、Widgets 库或媒体后端插件。
|
||||
- `diagnosis_media.py` 捕获 `ImportError` 并降级到外部打开,因此媒体模块缺失不会让应用启动失败。
|
||||
- 实测旧冻结包缺少上述模块/插件,但隔离执行 `DoctorWorkstation.exe --smoke-test` 仍返回 0。这证明现有烟测不是 QtMultimedia 发布门禁。
|
||||
|
||||
发布前至少应做到以下二者之一,建议二者都做:
|
||||
|
||||
1. 在 spec 中显式纳入 `PySide6.QtMultimedia` 和 `PySide6.QtMultimediaWidgets`,继续使用 PyInstaller 官方 hooks 收集平台插件。
|
||||
2. 在两个构建脚本中增加平台化的成品断言;Windows 检查两个 `.pyd`、`Qt6MultimediaWidgets.dll` 和 `plugins/multimedia`,macOS 检查两个 Python 扩展、Qt multimedia framework/dylib 和 `plugins/multimedia`。
|
||||
|
||||
最终验收还要用一个获准分发的 MP4(以及产品真实需要时的 HLS)实际播放,不能只看文件存在。
|
||||
|
||||
### P1-2:`--smoke-test` 只证明冻结启动,不证明 WebRTC/媒体可用
|
||||
|
||||
当前烟测的隔离、30 秒超时、非零退出码和未捕获异常日志检查设计良好;Windows/macOS 都不会访问真实后端。它会启动 composition root,并在约 1.2 秒后退出。
|
||||
|
||||
它不执行以下能力:
|
||||
|
||||
- 创建 `QMediaPlayer` 并确认媒体 backend 被发现;
|
||||
- 播放音视频并检查解码/渲染;
|
||||
- 打开 `QWebEngineView` 的 companion 主文档;
|
||||
- 执行 `TRTC.isSupported()`、请求摄像头/麦克风或加入真实测试房间;
|
||||
- 验证设备插拔、休眠恢复、弱网、屏幕共享或录播 codec。
|
||||
|
||||
因此冻结烟测应保留为启动门禁,同时新增“冻结媒体插件合同”和原生机器上的手工/自动媒体准入。README 已提示首发要做真实 TRTC/设备验收,这项不能被 `--smoke-test` 替代。
|
||||
|
||||
### P1-3:生产分发签名链未纳入一键打包
|
||||
|
||||
Windows:当前流水线生成 ZIP 与 SHA-256,但没有 Authenticode 签名步骤。SHA-256 提供完整性,不提供发布者身份;对外分发前应在压缩前签名并验证最终可执行文件。
|
||||
|
||||
macOS:spec 接受可选的 `MACOS_CODESIGN_IDENTITY`,构建脚本只运行 `codesign --verify --deep --strict`。未提供 Developer ID 时,PyInstaller 的临时/ad-hoc 签名也可能通过这一完整性检查;脚本不执行 notarization/stapling,也没有验证嵌套 `QtWebEngineProcess.app` 的实际 entitlements/运行能力。当前 README 仅把签名、公证作为人工说明。
|
||||
|
||||
若本次只是内部 QA 包,应明确标注“未签名/未公证内部构建”。若是生产或院外分发,应先把 Windows 签名,以及 macOS Developer ID → nested code 检查 → notarization → stapling → 重新生成最终 ZIP/哈希,纳入发布流水线。注意:staple 会改变 `.app`,所以不能继续分发公证前生成的 ZIP。
|
||||
|
||||
### P1-4:macOS 成品尚无原生验证证据
|
||||
|
||||
Windows 上的 `bash -n` 和合同脚本只能证明 shell 文本结构。以下证据必须在 macOS 构建机补齐:
|
||||
|
||||
- `.app` 原生架构与最低系统版本;
|
||||
- `QtWebEngineProcess.app`、Qt framework、QtMultimedia 插件实际存在并签名有效;
|
||||
- `NSCameraUsageDescription`/`NSMicrophoneUsageDescription` 能触发正确权限流程;
|
||||
- companion 的本地 `file:`/qrc 资源、WebChannel 和 TRTC 能在打包态工作;
|
||||
- arm64/x64 输出名与实际二进制架构一致;
|
||||
- 最终 ZIP 的 Gatekeeper/notary 结果。
|
||||
|
||||
这不是 Windows 端可以规避的检查;必须安排一台原生 macOS runner/构建机。
|
||||
|
||||
## P2:建议改进
|
||||
|
||||
1. 仓库没有 `.gitattributes`,而本机 `core.autocrlf=true`。当前 macOS shell 文件实际是无 BOM 的 LF,`bash -n` 通过,但建议用 `.gitattributes` 固定 `*.sh`/`*.command` 为 LF,并固定批处理/PowerShell 的预期行尾。
|
||||
2. Windows 使用 `uv sync --frozen`,macOS 使用 `uv sync --locked`。当前 `uv.lock` 包含所需依赖,但 `--frozen` 不承担 lock 新鲜度检查;发布前可增加 `uv lock --check`,或统一为能拒绝 pyproject/lock 漂移的策略。
|
||||
3. `packaging/windows/version_info.txt` 的 `0.1.0.0` 与 `pyproject.toml` 的 `0.1.0` 当前一致,但它是手工同步;脚本没有发布前一致性检查。升级版本时可能出现 ZIP 名与 EXE 版本资源不一致。
|
||||
4. README 顶部的一键入口描述正确,但“测试与打包”段落调用 `build_*`,只产生 onedir/.app,不产生版本化 ZIP/哈希。建议明确区分“构建”与“发布打包”,并把 `package_*`/根入口列为正式发布命令。
|
||||
5. `.env.example` 对 demo/生产、SSL、TRTC secret 禁止项说明清楚,未包含长期凭据;但发布 ZIP 不包含 README 或 `.env.example`,README 也没有明确冻结包从哪里查找 `.env`。若企业部署依赖环境文件,应说明冻结态放置/注入方式;若只允许登录页保存非敏感配置,也应明确说明无需随包放 `.env`。
|
||||
6. Windows 用固定 `dist/SHA256SUMS.txt`,macOS 用相邻的 `<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-11(Asia/Shanghai)
|
||||
目标:判断是否可以**从当前工作树**重建 DoctorWorkstation 发布包。
|
||||
边界:未执行完整 PyInstaller,未修改应用/打包源码;仅新增本复审报告。
|
||||
|
||||
## 最终结论
|
||||
|
||||
**结论:当前工作树已具备启动原生完整重建的条件,未发现构建前 packaging 代码级 P0。**
|
||||
|
||||
- Windows 当前工作树可进入 `Build_DoctorWorkstation.bat` 完整重建;根发布入口的 `-ValidateOnly` 已通过。
|
||||
- macOS 的 9 个操作入口在 Git index 中现已全部为 `100755`,入口合同会同时检查文件系统 `-x` 和 Git index mode,不再被 Windows/NTFS 的可执行位模拟掩盖。
|
||||
- spec 已显式收集 `PySide6.QtMultimedia`/`QtMultimediaWidgets`,并安装独立 PyInstaller runtime hook。
|
||||
- Windows/macOS build 均先检查冻结媒体文件,再依次执行 `--media-smoke-test` 与 `--smoke-test`;package 仅在 build 成功后归档和计算哈希。
|
||||
- README、packaging README 与 `.env.example` 已说明 build/package 区别、双媒体门禁、macOS mode 以及 `.env` 不进入发布包的部署策略。
|
||||
|
||||
本结论只表示“可以开始原生重建”,不表示两个平台产物已经生成或可以立即对外发布。旧 `dist` 仍是修复前成品,必须由新构建替换;macOS `.app` 仍只能在原生 macOS 上产出和验收。
|
||||
|
||||
## P0:无
|
||||
|
||||
按本轮定义——是否能从**当前本地工作树**发起完整重建——未发现 P0。
|
||||
|
||||
特别澄清:`src/doctor_workstation/ui/diagnosis_media.py`、`packaging/runtime_media_smoke.py`、`tests/test_packaging_media_gate.py` 等文件目前未跟踪,但它们真实存在于当前工作树,spec/测试/发布入口都能读取,因此**不是本地工作树构建阻断**。它们的未提交状态属于跨机和后续复现风险,列为 P1,而不是本轮 P0。
|
||||
|
||||
## P1:完整发布前仍须完成
|
||||
|
||||
### P1-1:必须运行新的原生完整构建,旧 `dist` 不得作为验收证据
|
||||
|
||||
当前 `dist/DoctorWorkstation` 和版本 ZIP 早于 QtMultimedia 修复,仍缺少新媒体模块/插件。根“一键运行”会优先启动这个旧 EXE,所以完整重建前不要用它判断当前代码。
|
||||
|
||||
Windows 必须重新运行:
|
||||
|
||||
```powershell
|
||||
.\Build_DoctorWorkstation.bat
|
||||
```
|
||||
|
||||
只有控制台依次报告以下门禁通过,且随后生成新 ZIP/哈希,才算 Windows 重建成功:
|
||||
|
||||
1. QtWebEngine helper/resources 与 TRTC companion 文件检查;
|
||||
2. `Frozen Qt multimedia file gate passed`;
|
||||
3. `Frozen Qt multimedia smoke gate passed (--media-smoke-test, ...)`;
|
||||
4. `Frozen application entry smoke gate passed (--smoke-test, ...)`;
|
||||
5. `Windows package complete`。
|
||||
|
||||
本复审按约束未运行完整 PyInstaller,因此不能提前宣称这些冻结态门禁已经在新产物上通过。
|
||||
|
||||
### P1-2:macOS 必须在原生目标架构主机完成构建
|
||||
|
||||
Windows 只能验证 shell 语法和合同,不能交叉产生 `.app`、macOS framework、媒体插件或有效签名。必须在 arm64 或 x86_64 macOS 构建机执行:
|
||||
|
||||
```bash
|
||||
CI=1 DOCTOR_NONINTERACTIVE=1 bash scripts/check_macos_entrypoints.sh
|
||||
./一键打包.command
|
||||
```
|
||||
|
||||
成功证据必须包括媒体文件 gate、两个冻结 smoke gate、`codesign --verify`、实际架构、版本 ZIP 与 `.sha256`。这是一项平台执行门禁,不是当前脚本缺陷。
|
||||
|
||||
### P1-3:当前工作树尚未形成可跨机复现的发布 commit/tag
|
||||
|
||||
当前本机直接构建会包含未跟踪媒体源码和 runtime hook,因此可以构建;但新的 clone、CI runner 或另一台 macOS 机器不会从当前 HEAD 获得这些文件。当前还有多处修改、staged mode 变化和大量测试产物。
|
||||
|
||||
因此:
|
||||
|
||||
- 内部本机技术构建可以现在开始;
|
||||
- 跨机 macOS 构建或正式发布前,发布负责人仍应审阅 intended source,提交必要文件及 9 个 mode 变化,并从确定的 commit/tag 构建;
|
||||
- 不应使用 `git add -A` 把 `.pytest-tmp-*`、`artifacts/` 等临时产物一并纳入。
|
||||
|
||||
这是正式发布的可复现性 P1,但按根任务要求不作为“当前工作树本地重建”P0。
|
||||
|
||||
### P1-4:生产签名、公证链仍是发布策略门禁
|
||||
|
||||
- Windows package 生成 ZIP 与 SHA-256,但没有集成 Authenticode。
|
||||
- macOS 接受可选 `MACOS_CODESIGN_IDENTITY` 并运行 `codesign --verify`,但不自动 notarize/staple;ad-hoc 签名也可能通过完整性验证。
|
||||
|
||||
内部 QA 包可以明确标记为内部构建。生产/院外分发则必须在归档前完成 Windows 签名;macOS 必须完成 Developer ID、嵌套 QtWebEngine helper 检查、notarization 和 stapling。staple 会改变 `.app`,之后要重新生成最终 ZIP 与 SHA-256。
|
||||
|
||||
### P1-5:媒体 gate 不替代真实播放和 TRTC 准入
|
||||
|
||||
新 `--media-smoke-test` 已真实创建 Qt 媒体对象并检查 decoder backend,但没有加载具体 MP4/HLS,也不请求摄像头/麦克风或加入 TRTC 房间。正式验收仍需在最终冻结包上覆盖:
|
||||
|
||||
- 获准分发的 MP4,产品要求时再覆盖 HLS;
|
||||
- 画面渲染与音频输出;
|
||||
- QtWebEngine companion/WebChannel;
|
||||
- 摄像头、麦克风、扬声器切换;
|
||||
- TRTC 测试房间、弱网、设备插拔和休眠恢复。
|
||||
|
||||
## P2:建议改进
|
||||
|
||||
1. 仓库仍无 `.gitattributes`,本机 `core.autocrlf=true`;当前 shell 文件是 LF 且 `bash -n` 通过,但建议固定 `*.sh`/`*.command` 为 LF。
|
||||
2. Windows 使用 `uv sync --frozen`,macOS 使用 `uv sync --locked`;建议补 `uv lock --check` 或统一 lock 新鲜度策略。
|
||||
3. Windows `version_info.txt` 的 `0.1.0.0` 与 pyproject `0.1.0` 当前一致,但仍是人工同步,建议增加版本一致性门禁。
|
||||
4. `Build_DoctorWorkstation.bat -ValidateOnly` 仍输出 `Package ready in: ...`,容易让人误以为已经生成新包;退出码和行为本身正确。
|
||||
5. Windows/macOS 的哈希文件命名分别为 `SHA256SUMS.txt` 与 `<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 spec:PASS
|
||||
|
||||
`packaging/doctor_workstation.spec` 现在:
|
||||
|
||||
- 显式 hidden import:
|
||||
- `PySide6.QtMultimedia`
|
||||
- `PySide6.QtMultimediaWidgets`
|
||||
- 继续显式收集 QtWebEngine 模块;
|
||||
- 将 `video_companion/dist` 放入 `video_companion_dist`;
|
||||
- 要求 `packaging/runtime_media_smoke.py` 必须存在;
|
||||
- 以 `runtime_hooks=[str(MEDIA_SMOKE_HOOK)]` 安装媒体 gate。
|
||||
|
||||
当前 PyInstaller 6.22 的官方 `hook-PySide6.QtMultimedia.py` 会追加 Widgets 模块,Qt6 module mapping 会收集 `plugins/multimedia`,与本 spec 的显式 imports 一致。
|
||||
|
||||
### 2. `--media-smoke-test`:PASS
|
||||
|
||||
`packaging/runtime_media_smoke.py` 作为 runtime hook,在普通启动时无副作用;仅当 argv 含 `--media-smoke-test` 时才在应用入口前执行:
|
||||
|
||||
- 导入 `QMediaPlayer`、`QAudioOutput`、`QMediaFormat`、`QVideoWidget`;
|
||||
- 创建并连接 player/audio/video 对象;
|
||||
- `player.isAvailable()` 必须为真;
|
||||
- Decode 模式至少暴露一种支持格式;
|
||||
- 显示 16×16 offscreen video widget,并完成一次 Qt event loop;
|
||||
- 任一错误固定返回 70,成功返回 0。
|
||||
|
||||
独立测试验证了三种状态:普通 argv 惰性退出 0;隔离掉 site-packages 后媒体 argv 返回 70;当前 PySide6 环境 offscreen 实例化返回 0。
|
||||
|
||||
### 3. Windows build/package:PASS(静态与轻量合同)
|
||||
|
||||
`scripts/build_windows.ps1` 在 PyInstaller 后要求以下文件:
|
||||
|
||||
- `QtMultimedia.pyd`
|
||||
- `QtMultimediaWidgets.pyd`
|
||||
- `Qt6Multimedia.dll`
|
||||
- `Qt6MultimediaWidgets.dll`
|
||||
- `plugins/multimedia/ffmpegmediaplugin.dll`
|
||||
- `plugins/multimedia/windowsmediaplugin.dll`
|
||||
|
||||
随后先运行 `--media-smoke-test`,再运行原 `--smoke-test`;两者均沿用隔离配置目录、loopback-only proxy、offscreen、30 秒超时、退出码和未捕获异常日志检查。
|
||||
|
||||
`scripts/package_windows.ps1` 把 runtime hook 加入 required files,调用 build 之后才复制 release launcher、压缩 ZIP 和生成 SHA-256。`Build_DoctorWorkstation.bat -ValidateOnly` 本轮通过。
|
||||
|
||||
### 4. macOS build/package:PASS(静态与轻量合同)
|
||||
|
||||
`scripts/build_macos.sh` 在成功前要求:
|
||||
|
||||
- `PySide6.QtMultimedia` 与 `QtMultimediaWidgets` Python `.so`;
|
||||
- 对应 framework,或适配 dylib 布局的后备检查;
|
||||
- `plugins/multimedia` 目录;
|
||||
- 至少一个 `*mediaplugin*.dylib/.so` backend;
|
||||
- QtWebEngine helper/resources、companion 与主可执行文件。
|
||||
|
||||
之后执行 `codesign --verify`,再顺序运行媒体 smoke 和应用 smoke。`scripts/package_macos.sh` 在准备依赖前先运行入口合同,并只在 build 全部成功后用 `ditto` 归档和计算哈希。
|
||||
|
||||
这些路径模式与当前 PySide6/PyInstaller 的 macOS framework/plugin 布局相容;最终仍需原生构建确认。
|
||||
|
||||
### 5. macOS Git executable mode:PASS
|
||||
|
||||
以下 9 个文件的 index mode 本轮均为 `100755`:
|
||||
|
||||
1. `package_macos.command`
|
||||
2. `run_macos.command`
|
||||
3. `一键打包.command`
|
||||
4. `一键运行.command`
|
||||
5. `scripts/build_macos.sh`
|
||||
6. `scripts/check_macos_entrypoints.sh`
|
||||
7. `scripts/macos_helpers.sh`
|
||||
8. `scripts/package_macos.sh`
|
||||
9. `scripts/run_macos.sh`
|
||||
|
||||
`scripts/check_macos_entrypoints.sh` 已将自身纳入 operational files,逐个执行 `bash -n`、检查实际 `-x`,在 Git 工作树中还读取 index mode 并要求 `100755`。本轮 Git Bash 合同执行通过。
|
||||
|
||||
### 6. README 与 `.env`:PASS
|
||||
|
||||
- 根 README 正确区分 `build_*`(只生成/验证 onedir 或 `.app`)和根/package 入口(生成版本 ZIP 与哈希)。
|
||||
- Windows/macOS 一键打包描述已包含 QtWebEngine/QtMultimedia 文件门禁与两个冻结 smoke。
|
||||
- README 明确 macOS 操作文件必须以 mode `100755` 跟踪。
|
||||
- `.env.example` 与 README 明确该文件不进入 ZIP/`.app`;生产配置应由受控 launcher/MDM/进程环境注入。
|
||||
- 文档明确禁止把密码、token、UserSig、TRTC SecretKey 等凭据放进 `.env` 或发布包。
|
||||
|
||||
### 7. 输出合同:PASS(待新构建兑现)
|
||||
|
||||
输出命名未被修复破坏:
|
||||
|
||||
- Windows onedir:`dist/DoctorWorkstation/`
|
||||
- Windows ZIP:`dist/DoctorWorkstation-Windows-x64-<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-11(Asia/Shanghai)
|
||||
范围:仅 PyInstaller spec、打包/构建/入口检查脚本、发布说明、环境示例与打包专属测试;未修改 UI、core 或 repository,未执行完整 PyInstaller。
|
||||
|
||||
## 结论
|
||||
|
||||
`release_packaging_audit.md` 中除 Git 工作树/提交状态和 macOS Git executable mode 本身之外的 QtMultimedia 发布门禁已经落到代码与合同测试中:
|
||||
|
||||
- spec 显式列出 `PySide6.QtMultimedia`、`PySide6.QtMultimediaWidgets`,从而强制触发 PyInstaller 官方 PySide6 hooks,收集对应 Python 扩展、`Qt6Multimedia`/`Qt6MultimediaWidgets` DLL、dylib/framework、`plugins/multimedia` 以及平台媒体后端。
|
||||
- 新增 PyInstaller runtime hook:冻结程序收到 `--media-smoke-test` 时,在进入正常应用入口前真实导入两个媒体模块,实例化 `QMediaPlayer`、`QAudioOutput`、`QVideoWidget`,连接音视频输出,确认媒体 backend 可用且至少暴露一种解码格式,并在 `offscreen` Qt 事件循环中退出。任一导入、链接、backend 或事件循环失败均返回非零(固定失败码 70)。
|
||||
- Windows 构建在成功前要求 `QtMultimedia.pyd`、`QtMultimediaWidgets.pyd`、`Qt6Multimedia.dll`、`Qt6MultimediaWidgets.dll`、`plugins/multimedia/ffmpegmediaplugin.dll` 和 `windowsmediaplugin.dll`,随后依次运行冻结媒体门禁与原应用 `--smoke-test`。
|
||||
- macOS 构建在成功前要求两个 Python 扩展、两个对应 framework/dylib、`plugins/multimedia` 与至少一个 `*mediaplugin*` backend,随后依次运行相同两项冻结门禁。
|
||||
- 两个平台的一键 package 入口仍先调用 build;只有 build 的文件门禁和两项冻结 smoke gate 全部通过后才归档 ZIP/生成 SHA-256。
|
||||
- `.env.example` 和 README 已明确:环境示例不会进入发布包,生产配置应由受控启动器/设备管理/进程环境注入,发布包不得包含密码、token、UserSig、TRTC SecretKey 等凭据。
|
||||
|
||||
## 主要文件
|
||||
|
||||
- `packaging/doctor_workstation.spec`
|
||||
- `packaging/runtime_media_smoke.py`
|
||||
- `scripts/build_windows.ps1`
|
||||
- `scripts/package_windows.ps1`
|
||||
- `scripts/build_macos.sh`
|
||||
- `scripts/package_macos.sh`
|
||||
- `scripts/check_macos_entrypoints.sh`
|
||||
- `packaging/README.md`
|
||||
- `README.md`
|
||||
- `.env.example`
|
||||
- `tests/test_packaging_media_gate.py`
|
||||
|
||||
## macOS executable mode:必须由根代理完成
|
||||
|
||||
本任务遵守约束,没有执行 `git add`、`git update-index` 或 `git commit`。当前 Git index 仍把下列 9 个入口记为 `100644`;根代理必须把每个文件的 index mode 精确改为 `100755`,并把 mode 变化纳入最终发布提交:
|
||||
|
||||
1. `run_macos.command` → `100755`
|
||||
2. `package_macos.command` → `100755`
|
||||
3. `一键运行.command` → `100755`
|
||||
4. `一键打包.command` → `100755`
|
||||
5. `scripts/build_macos.sh` → `100755`
|
||||
6. `scripts/package_macos.sh` → `100755`
|
||||
7. `scripts/run_macos.sh` → `100755`
|
||||
8. `scripts/macos_helpers.sh` → `100755`
|
||||
9. `scripts/check_macos_entrypoints.sh` → `100755`
|
||||
|
||||
根代理可从项目根目录执行以下精确操作(本代理未执行):
|
||||
|
||||
```powershell
|
||||
git update-index --chmod=+x -- run_macos.command package_macos.command `
|
||||
"一键运行.command" "一键打包.command" `
|
||||
scripts/build_macos.sh scripts/package_macos.sh scripts/run_macos.sh `
|
||||
scripts/macos_helpers.sh scripts/check_macos_entrypoints.sh
|
||||
git ls-files --stage -- '*.command' 'scripts/*.sh'
|
||||
```
|
||||
|
||||
第二条命令对上述 9 个文件必须全部显示 `100755`。`scripts/check_macos_entrypoints.sh` 现在同时检查实际 `-x` 与 Git index mode,因此 Windows/Git Bash 将文件视为可执行也不能再掩盖 `100644`。
|
||||
|
||||
## 已执行的轻量验证
|
||||
|
||||
- 源环境媒体门禁(`QT_QPA_PLATFORM=offscreen`):PASS;实际播放器、音频输出、VideoWidget 和 decoder backend 均成功创建。
|
||||
- 缺少 site-packages 的隔离进程:PASS;`--media-smoke-test` 返回 70,证明缺组件不会误报成功。
|
||||
- PowerShell parser:`build_windows.ps1`、`package_windows.ps1`、`run_windows.ps1` PASS。
|
||||
- Git Bash `bash -n`:所有 macOS build/package/run/check/helper 与四个 `.command` PASS。
|
||||
- 打包合同测试:`tests/test_packaging_media_gate.py`、`tests/test_one_click_entrypoints.py`、`tests/test_entrypoint.py`,10 passed。
|
||||
- Ruff:新增 runtime hook 与打包测试 PASS。
|
||||
- `git diff --check`:PASS。
|
||||
|
||||
## 仍需原生构建机完成的发布验收
|
||||
|
||||
按任务约束,本次没有运行完整 PyInstaller。最终发布仍必须:
|
||||
|
||||
- 在 Windows x64 原生构建机运行一键打包,确认新 onedir/ZIP 的文件门禁和冻结 `--media-smoke-test` 均通过,替换旧 `dist` 证据;
|
||||
- 在目标架构 macOS 原生构建机运行一键打包,确认 `.app` framework/plugin 布局、codesign、摄像头/麦克风权限、notarization/stapling;
|
||||
- 使用批准分发的 MP4(产品需要时再加 HLS)完成实际解码、画面渲染和音频输出验收;媒体 smoke gate 是 backend 准入,不替代真实播放;
|
||||
- 由发布负责人审阅工作树,只从确定的 commit/tag 构建。Git 输入/提交状态属于本任务明确排除项。
|
||||
|
||||
补充:仓库指令引用的 `.trellis/workflow.md` 与 `.trellis/spec/` 在当前工作区不存在,因此本次无法执行 Trellis 规范审查;这不影响上述脚本合同结果。
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||