This commit is contained in:
Your Name
2026-08-11 09:12:51 +08:00
parent c3ceb0dd0f
commit cfe4c82c90
111 changed files with 26110 additions and 826 deletions
+99 -53
View File
@@ -29,11 +29,44 @@ helper="$(find "$artifact" -type f -name 'QtWebEngineProcess' -print -quit)"
resources="$(find "$artifact" -type f -name 'qtwebengine_resources*.pak' -print -quit)"
companion="$(find "$artifact" -type f -path '*video_companion_dist/index.html' -print -quit)"
executable="$artifact/Contents/MacOS/DoctorWorkstation"
qt_multimedia_module="$(find "$artifact" -type f -name 'QtMultimedia*.so' \
! -name 'QtMultimediaWidgets*.so' -print -quit)"
qt_multimedia_widgets_module="$(find "$artifact" -type f -name 'QtMultimediaWidgets*.so' -print -quit)"
qt_multimedia_framework="$(find "$artifact" -type d -name 'QtMultimedia.framework' -print -quit)"
qt_multimedia_widgets_framework="$(find "$artifact" -type d -name 'QtMultimediaWidgets.framework' -print -quit)"
qt_multimedia_dylib="$(find "$artifact" -type f -name '*Qt6Multimedia*.dylib' \
! -name '*Widgets*' ! -name '*Quick*' -print -quit)"
qt_multimedia_widgets_dylib="$(find "$artifact" -type f -name '*Qt6MultimediaWidgets*.dylib' -print -quit)"
multimedia_plugin_dir="$(find "$artifact" -type d -path '*/plugins/multimedia' -print -quit)"
multimedia_backend=""
if [[ -n "$multimedia_plugin_dir" ]]; then
multimedia_backend="$(find "$multimedia_plugin_dir" -type f \
\( -name '*mediaplugin*.dylib' -o -name '*mediaplugin*.so' \) -print -quit)"
fi
[[ -n "$helper" ]] || { echo "QtWebEngineProcess is missing from the app." >&2; exit 1; }
[[ -n "$resources" ]] || { echo "QtWebEngine resources are missing from the app." >&2; exit 1; }
[[ -n "$companion" ]] || { echo "video_companion_dist is missing from the app." >&2; exit 1; }
[[ -x "$executable" ]] || { echo "Frozen application entry point is missing." >&2; exit 1; }
[[ -n "$qt_multimedia_module" ]] || {
echo "PySide6.QtMultimedia is missing from the app." >&2; exit 1;
}
[[ -n "$qt_multimedia_widgets_module" ]] || {
echo "PySide6.QtMultimediaWidgets is missing from the app." >&2; exit 1;
}
[[ -n "$qt_multimedia_framework" || -n "$qt_multimedia_dylib" ]] || {
echo "Qt6Multimedia framework/dylib is missing from the app." >&2; exit 1;
}
[[ -n "$qt_multimedia_widgets_framework" || -n "$qt_multimedia_widgets_dylib" ]] || {
echo "Qt6MultimediaWidgets framework/dylib is missing from the app." >&2; exit 1;
}
[[ -n "$multimedia_plugin_dir" ]] || {
echo "PySide6 plugins/multimedia is missing from the app." >&2; exit 1;
}
[[ -n "$multimedia_backend" ]] || {
echo "A Qt multimedia platform/FFmpeg backend plugin is missing from the app." >&2; exit 1;
}
echo "Frozen Qt multimedia file gate passed."
codesign --verify --deep --strict --verbose=2 "$artifact"
@@ -55,58 +88,71 @@ mkdir -p \
"$smoke_root/xdg/cache" \
"$smoke_root/tmp"
set +e
env \
TMPDIR="$smoke_root/tmp" \
XDG_CONFIG_HOME="$smoke_root/xdg/config" \
XDG_STATE_HOME="$smoke_root/xdg/state" \
XDG_CACHE_HOME="$smoke_root/xdg/cache" \
DOCTOR_CONFIG_DIR="$smoke_root/doctor/config" \
DOCTOR_LOG_DIR="$smoke_root/doctor/logs" \
DOCTOR_API_BASE_URL="https://127.0.0.1:9" \
DOCTOR_DEMO_MODE="true" \
DOCTOR_VIDEO_MODE="embedded" \
DOCTOR_VIDEO_WEB_URL="" \
DOCTOR_VERIFY_SSL="true" \
DOCTOR_LOG_LEVEL="INFO" \
DOCTOR_SMOKE_TEST="1" \
HTTP_PROXY="http://127.0.0.1:9" \
HTTPS_PROXY="http://127.0.0.1:9" \
ALL_PROXY="http://127.0.0.1:9" \
NO_PROXY="" \
QT_QPA_PLATFORM="offscreen" \
"$executable" --smoke-test >"$smoke_root/stdout.txt" 2>"$smoke_root/stderr.txt" &
smoke_pid=$!
smoke_running=1
for _ in {1..300}; do
if ! kill -0 "$smoke_pid" 2>/dev/null; then
smoke_running=0
break
fi
sleep 0.1
done
if [[ "$smoke_running" == "1" ]]; then
kill "$smoke_pid" 2>/dev/null
wait "$smoke_pid" 2>/dev/null
smoke_status=124
else
wait "$smoke_pid"
smoke_status=$?
fi
set -e
run_frozen_gate() {
local gate_slug="$1"
local gate_label="$2"
local gate_argument="$3"
local stdout_file="$smoke_root/$gate_slug-stdout.txt"
local stderr_file="$smoke_root/$gate_slug-stderr.txt"
local diagnostics="$smoke_root/$gate_slug-diagnostics.txt"
local smoke_pid smoke_running smoke_status
if [[ "$smoke_status" -ne 0 ]]; then
cat "$smoke_root/stderr.txt" >&2
echo "Frozen application smoke test failed with exit code $smoke_status." >&2
exit 1
fi
diagnostics="$smoke_root/diagnostics.txt"
cat "$smoke_root/stdout.txt" "$smoke_root/stderr.txt" >"$diagnostics"
find "$smoke_root" -type f -name '*.log' -exec cat {} + >>"$diagnostics"
if grep -Eiq 'traceback \(most recent call last\)|unhandled exception|uncaught exception|fatal python error' "$diagnostics"; then
cat "$diagnostics" >&2
echo "Frozen application smoke-test logs contain an unhandled exception." >&2
exit 1
fi
echo "Frozen entry smoke test passed (--smoke-test, isolated demo mode)."
env \
TMPDIR="$smoke_root/tmp" \
XDG_CONFIG_HOME="$smoke_root/xdg/config" \
XDG_STATE_HOME="$smoke_root/xdg/state" \
XDG_CACHE_HOME="$smoke_root/xdg/cache" \
DOCTOR_CONFIG_DIR="$smoke_root/doctor/config" \
DOCTOR_LOG_DIR="$smoke_root/doctor/logs" \
DOCTOR_API_BASE_URL="https://127.0.0.1:9" \
DOCTOR_DEMO_MODE="true" \
DOCTOR_VIDEO_MODE="embedded" \
DOCTOR_VIDEO_WEB_URL="" \
DOCTOR_VERIFY_SSL="true" \
DOCTOR_LOG_LEVEL="INFO" \
DOCTOR_SMOKE_TEST="1" \
HTTP_PROXY="http://127.0.0.1:9" \
HTTPS_PROXY="http://127.0.0.1:9" \
ALL_PROXY="http://127.0.0.1:9" \
NO_PROXY="" \
QT_QPA_PLATFORM="offscreen" \
"$executable" "$gate_argument" >"$stdout_file" 2>"$stderr_file" &
smoke_pid=$!
smoke_running=1
for _ in {1..300}; do
if ! kill -0 "$smoke_pid" 2>/dev/null; then
smoke_running=0
break
fi
sleep 0.1
done
if [[ "$smoke_running" == "1" ]]; then
kill "$smoke_pid" 2>/dev/null || true
wait "$smoke_pid" 2>/dev/null || true
smoke_status=124
elif wait "$smoke_pid"; then
smoke_status=0
else
smoke_status=$?
fi
if [[ "$smoke_status" -ne 0 ]]; then
cat "$stderr_file" >&2
echo "$gate_label failed with exit code $smoke_status." >&2
exit 1
fi
cat "$stdout_file" "$stderr_file" >"$diagnostics"
find "$smoke_root" -type f -name '*.log' -exec cat {} + >>"$diagnostics"
if grep -Eiq 'traceback \(most recent call last\)|unhandled exception|uncaught exception|fatal python error' "$diagnostics"; then
cat "$diagnostics" >&2
echo "$gate_label logs contain an unhandled exception." >&2
exit 1
fi
echo "$gate_label passed ($gate_argument, isolated offscreen mode)."
}
run_frozen_gate \
"media" "Frozen Qt multimedia smoke gate" "--media-smoke-test"
run_frozen_gate \
"entry" "Frozen application entry smoke gate" "--smoke-test"
echo "Build complete: $artifact"
+56 -8
View File
@@ -9,8 +9,12 @@ $ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$CompanionRoot = Join-Path $ProjectRoot "video_companion"
$Spec = Join-Path $ProjectRoot "packaging\doctor_workstation.spec"
function Invoke-FrozenSmokeTest {
param([Parameter(Mandatory = $true)][string]$Executable)
function Invoke-FrozenGate {
param(
[Parameter(Mandatory = $true)][string]$Executable,
[Parameter(Mandatory = $true)][string]$Argument,
[Parameter(Mandatory = $true)][string]$GateName
)
$TempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
$SmokeRoot = Join-Path $TempRoot ("doctor-workstation-smoke-" + [guid]::NewGuid().ToString("N"))
@@ -59,7 +63,7 @@ function Invoke-FrozenSmokeTest {
# and lets us drain redirected streams without risking a pipe deadlock.
$ProcessInfo = New-Object System.Diagnostics.ProcessStartInfo
$ProcessInfo.FileName = $Executable
$ProcessInfo.Arguments = "--smoke-test"
$ProcessInfo.Arguments = $Argument
$ProcessInfo.UseShellExecute = $false
$ProcessInfo.CreateNoWindow = $true
$ProcessInfo.RedirectStandardOutput = $true
@@ -87,15 +91,15 @@ function Invoke-FrozenSmokeTest {
$Diagnostics = ($DiagnosticFiles | Where-Object { Test-Path -LiteralPath $_ } |
ForEach-Object { Get-Content -LiteralPath $_ -Raw -ErrorAction SilentlyContinue }) -join "`n"
if ($TimedOut) {
throw "Frozen application smoke test timed out after 30 seconds`n$Diagnostics"
throw "$GateName timed out after 30 seconds`n$Diagnostics"
}
if ($Process.ExitCode -ne 0) {
throw "Frozen application smoke test failed with exit code $($Process.ExitCode)`n$Diagnostics"
throw "$GateName failed with exit code $($Process.ExitCode)`n$Diagnostics"
}
if ($Diagnostics -match "(?im)traceback \(most recent call last\)|unhandled exception|uncaught exception|fatal python error") {
throw "Frozen application smoke-test logs contain an unhandled exception"
throw "$GateName logs contain an unhandled exception"
}
Write-Host "Frozen entry smoke test passed (--smoke-test, isolated demo mode)."
Write-Host "$GateName passed ($Argument, isolated offscreen mode)."
}
finally {
foreach ($Name in $Environment.Keys) {
@@ -107,6 +111,42 @@ function Invoke-FrozenSmokeTest {
}
}
function Assert-FrozenMultimedia {
param([Parameter(Mandatory = $true)][string]$Artifact)
foreach ($RequiredName in @(
"QtMultimedia.pyd",
"QtMultimediaWidgets.pyd",
"Qt6Multimedia.dll",
"Qt6MultimediaWidgets.dll"
)) {
$RequiredFile = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter $RequiredName -File |
Where-Object { $_.FullName -like "*\PySide6\*" } |
Select-Object -First 1
if (-not $RequiredFile) {
throw "Frozen Qt multimedia component is missing: $RequiredName"
}
}
$PluginDirectories = @(Get-ChildItem -LiteralPath $Artifact -Recurse -Directory |
Where-Object { $_.Name -eq "multimedia" -and $_.Parent.Name -eq "plugins" })
if ($PluginDirectories.Count -eq 0) {
throw "Frozen PySide6 plugins/multimedia directory is missing"
}
foreach ($BackendName in @("ffmpegmediaplugin.dll", "windowsmediaplugin.dll")) {
$Backend = $PluginDirectories |
ForEach-Object {
Get-ChildItem -LiteralPath $_.FullName -Filter $BackendName -File -ErrorAction SilentlyContinue
} |
Select-Object -First 1
if (-not $Backend) {
throw "Frozen Qt multimedia backend is missing: $BackendName"
}
}
Write-Host "Frozen Qt multimedia file gate passed."
}
if (-not [System.IO.Path]::IsPathRooted($Python)) {
$Python = Join-Path $ProjectRoot $Python
}
@@ -162,7 +202,15 @@ try {
if (-not (Test-Path -LiteralPath $Executable -PathType Leaf)) {
throw "Frozen application entry point is missing: $Executable"
}
Invoke-FrozenSmokeTest -Executable $Executable
Assert-FrozenMultimedia -Artifact $Artifact
Invoke-FrozenGate `
-Executable $Executable `
-Argument "--media-smoke-test" `
-GateName "Frozen Qt multimedia smoke gate"
Invoke-FrozenGate `
-Executable $Executable `
-Argument "--smoke-test" `
-GateName "Frozen application entry smoke gate"
Write-Host "Build complete: $Artifact"
}
+24 -9
View File
@@ -5,6 +5,7 @@ script_dir="$(cd "$(dirname "$0")" && pwd -P)"
project_root="$(cd "$script_dir/.." && pwd -P)"
operational_files=(
"$script_dir/check_macos_entrypoints.sh"
"$script_dir/macos_helpers.sh"
"$script_dir/run_macos.sh"
"$script_dir/package_macos.sh"
@@ -18,14 +19,28 @@ operational_files=(
for file in "${operational_files[@]}"; do
[[ -f "$file" ]] || { printf 'Missing macOS entry file: %s\n' "$file" >&2; exit 1; }
/bin/bash -n "$file"
[[ -x "$file" ]] || { printf 'macOS entry is not executable: %s\n' "$file" >&2; exit 1; }
done
for file in \
"$project_root/一键运行.command" \
"$project_root/一键打包.command" \
"$project_root/run_macos.command" \
"$project_root/package_macos.command"; do
[[ -x "$file" ]] || { printf 'Finder entry is not executable: %s\n' "$file" >&2; exit 1; }
done
# A Windows checkout can report every shell file as executable even when Git
# records mode 100644. Check the index as well so a fresh macOS clone retains
# Finder/CLI launchability. Source archives without .git still use the -x gate
# above.
if command -v git >/dev/null 2>&1 && \
git -C "$project_root" rev-parse --is-inside-work-tree >/dev/null 2>&1 && \
git -C "$project_root" ls-files --error-unmatch -- \
packaging/doctor_workstation.spec >/dev/null 2>&1; then
for file in "${operational_files[@]}"; do
relative_path="${file#"$project_root"/}"
index_record="$(git -C "$project_root" ls-files --stage -- "$relative_path")"
index_mode="${index_record%% *}"
[[ "$index_mode" == "100755" ]] || {
printf 'Git index mode must be 100755 for macOS entry: %s (found %s)\n' \
"$relative_path" "${index_mode:-untracked}" >&2
exit 1
}
done
fi
open_line="$(grep -nF '/usr/bin/open "$artifact"' "$script_dir/run_macos.sh" | head -n 1 | cut -d: -f1)"
source_line="$(grep -nF 'macos_ensure_uv' "$script_dir/run_macos.sh" | head -n 1 | cut -d: -f1)"
@@ -46,11 +61,11 @@ grep -Fq 'scripts/run_macos.sh' "$project_root/run_macos.command"
grep -Fq 'scripts/package_macos.sh' "$project_root/一键打包.command"
grep -Fq 'scripts/package_macos.sh' "$project_root/package_macos.command"
if grep -En '(^|[[:space:]])(export[[:space:]]+)?HOME=' "${operational_files[@]}"; then
if grep -En '(^|[[:space:]])(export[[:space:]]+)?HOME=' "${operational_files[@]:1}"; then
echo 'macOS entry scripts must not repurpose HOME.' >&2
exit 1
fi
if grep -Ein 'SDKSecret(Key)?|UserSig|userSig' "${operational_files[@]}"; then
if grep -Ein 'SDKSecret(Key)?|UserSig|userSig' "${operational_files[@]:1}"; then
echo 'macOS entry scripts must not contain RTC secrets or credentials.' >&2
exit 1
fi
+1
View File
@@ -7,6 +7,7 @@ project_root="$(cd "$script_dir/.." && pwd -P)"
source "$script_dir/macos_helpers.sh"
macos_install_exit_trap 1
macos_require_darwin
/bin/bash "$script_dir/check_macos_entrypoints.sh"
NODE_BIN=""
NPM_BIN=""
+18
View File
@@ -7,6 +7,7 @@ $ErrorActionPreference = "Stop"
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$BuildScript = Join-Path $PSScriptRoot "build_windows.ps1"
$BuildEnvironment = Join-Path $ProjectRoot ".venv-build"
$ProjectUvCache = Join-Path $ProjectRoot ".uv-cache"
$BuildPython = Join-Path $BuildEnvironment "Scripts\python.exe"
$FallbackPython = Join-Path $ProjectRoot ".venv\Scripts\python.exe"
$CompanionRoot = Join-Path $ProjectRoot "video_companion"
@@ -17,6 +18,7 @@ $DistributionRoot = Join-Path $ProjectRoot "dist"
$ReleaseLauncherTemplate = Join-Path $ProjectRoot "packaging\windows\start_release.bat"
$ReleaseLauncher = Join-Path $DistributionRoot "Start_DoctorWorkstation.bat"
$ProjectMetadata = Join-Path $ProjectRoot "pyproject.toml"
$MediaSmokeHook = Join-Path $ProjectRoot "packaging\runtime_media_smoke.py"
function Test-BuildPython {
param([Parameter(Mandatory = $true)][string]$Candidate)
@@ -48,6 +50,7 @@ try {
$PackageLock,
(Join-Path $ProjectRoot "uv.lock"),
$ProjectMetadata,
$MediaSmokeHook,
$ReleaseLauncherTemplate
)) {
if (-not (Test-Path -LiteralPath $RequiredFile -PathType Leaf)) {
@@ -83,11 +86,21 @@ try {
"UV_PROJECT_ENVIRONMENT",
"Process"
)
$PreviousUvCache = [Environment]::GetEnvironmentVariable(
"UV_CACHE_DIR",
"Process"
)
New-Item -ItemType Directory -Path $ProjectUvCache -Force | Out-Null
[Environment]::SetEnvironmentVariable(
"UV_PROJECT_ENVIRONMENT",
$BuildEnvironment,
"Process"
)
[Environment]::SetEnvironmentVariable(
"UV_CACHE_DIR",
$ProjectUvCache,
"Process"
)
Push-Location $ProjectRoot
try {
& $Uv sync --frozen --extra build
@@ -102,6 +115,11 @@ try {
$PreviousProjectEnvironment,
"Process"
)
[Environment]::SetEnvironmentVariable(
"UV_CACHE_DIR",
$PreviousUvCache,
"Process"
)
}
}
elseif (Test-BuildPython -Candidate $BuildPython) {
@@ -0,0 +1,394 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QDate, QPoint, QSize, Qt
from PySide6.QtGui import QColor, QFont, QFontDatabase, QImage, QPainter, QPixmap
from PySide6.QtWidgets import (
QApplication,
QFrame,
QHBoxLayout,
QLabel,
QVBoxLayout,
QWidget,
)
from doctor_workstation.ui.appointment_drawer import AppointmentDrawer
ROOT = Path(__file__).resolve().parents[1]
OUTPUT_DIR = ROOT / "artifacts" / "diagnosis_visual"
def _immediate_async(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**kwargs: Any,
) -> object:
try:
result = function(*args, **kwargs)
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
class _DeferredAsync:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
def __call__(
self,
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**kwargs: Any,
) -> object:
self.calls.append(
{
"function": function,
"args": args,
"kwargs": kwargs,
"on_success": on_success,
"on_error": on_error,
"on_finished": on_finished,
}
)
return object()
class _ScreenshotRepository:
def __init__(
self,
*,
today_conflict: bool = True,
doctors: bool = True,
rosters: bool = True,
slot_error: bool = False,
) -> None:
self.today_conflict = today_conflict
self.doctors = doctors
self.rosters = rosters
self.slot_error = slot_error
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
if not self.doctors:
return []
return [
{"id": 77, "name": "陈医生", "department_name": "中医科"},
{"id": 88, "name": "周医生", "department_name": "内科"},
{"id": 99, "name": "林医生", "department_name": "全科"},
{"id": 106, "name": "宋医生", "department_name": "康复科"},
]
def get_dictionary(self, dictionary_type: str) -> list[dict[str, Any]]:
assert dictionary_type == "channels"
return [
{"id": 3, "name": "自媒体4H", "value": "self-4h", "status": 1, "sort": 30},
{"id": 2, "name": "线上复诊", "value": "online", "status": 1, "sort": 20},
{"id": 1, "name": "医生推荐", "value": "doctor", "status": 1, "sort": 10},
]
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
if kwargs.get("status") == 3:
return {
"lists": [
{
"appointment_date": "2026-08-01",
"appointment_time": "10:30-11:00",
}
],
"count": 1,
}
if self.today_conflict:
return {"lists": [{"status": 1}], "count": 1}
return {"lists": [], "count": 0}
def list_appointment_rosters(self, **_kwargs: Any) -> dict[str, Any]:
if not self.rosters:
return {"lists": [], "count": 0}
today = QDate.currentDate()
return {
"lists": [
{"date": today.addDays(offset).toString("yyyy-MM-dd")} for offset in range(4)
],
"count": 4,
}
def get_available_appointment_slots(self, **_kwargs: Any) -> dict[str, Any]:
if self.slot_error:
raise RuntimeError("号源服务暂时不可用")
return {
"slots": [
{"time": "09:00-09:30", "available": True, "quota": 2},
{"time": "09:30-10:00", "available": False, "quota": 0},
{"time": "10:00-10:30", "available": True, "quota": 1},
{"time": "10:30-11:00", "available": True, "quota": 3},
{"time": "14:00-14:30", "available": False, "quota": 0},
{"time": "14:30-15:00", "available": True, "quota": 2},
{"time": "15:00-15:30", "available": True, "quota": 1},
{"time": "15:30-16:00", "available": False, "quota": 0},
]
}
def _background(size: tuple[int, int]) -> QWidget:
host = QWidget()
host.setObjectName("AppointmentScreenshotHost")
host.resize(*size)
host.setStyleSheet(
"QWidget#AppointmentScreenshotHost { background:#F5F7FA; color:#303133; }"
"QFrame#Sidebar { background:#1F2D3D; }"
"QFrame#Header, QFrame#Card { background:#FFFFFF; border:1px solid #EBEEF5; }"
"QLabel#Nav { color:#DDE5ED; font-size:14px; }"
"QLabel#Muted { color:#909399; }"
)
root = QHBoxLayout(host)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
sidebar = QFrame()
sidebar.setObjectName("Sidebar")
sidebar.setFixedWidth(196)
side_layout = QVBoxLayout(sidebar)
side_layout.setContentsMargins(22, 26, 22, 26)
side_layout.setSpacing(18)
brand = QLabel("真养堂 · 医生工作站")
brand.setObjectName("Nav")
side_layout.addWidget(brand)
for item in ("诊单列表", "患者管理", "预约挂号", "处方管理", "业务订单"):
label = QLabel(item)
label.setObjectName("Nav")
side_layout.addWidget(label)
side_layout.addStretch(1)
root.addWidget(sidebar)
content = QWidget()
content_layout = QVBoxLayout(content)
content_layout.setContentsMargins(18, 18, 18, 18)
content_layout.setSpacing(14)
header = QFrame()
header.setObjectName("Header")
header.setFixedHeight(58)
header_layout = QHBoxLayout(header)
header_layout.setContentsMargins(18, 0, 18, 0)
header_layout.addWidget(QLabel("诊单列表"))
header_layout.addStretch(1)
account = QLabel("陈医生 · 中医科")
account.setObjectName("Muted")
header_layout.addWidget(account)
content_layout.addWidget(header)
card = QFrame()
card.setObjectName("Card")
card_layout = QVBoxLayout(card)
card_layout.setContentsMargins(20, 18, 20, 18)
card_layout.setSpacing(14)
card_layout.addWidget(QLabel("患者诊单 / 林晓岚 / 待预约"))
for line in (
"林晓岚 女 · 38岁 最近问诊 2026-08-01",
"主诉:失眠、多梦,复诊评估调方",
"接诊医生:陈医生 医助:赵医助",
):
value = QLabel(line)
value.setObjectName("Muted")
card_layout.addWidget(value)
card_layout.addStretch(1)
content_layout.addWidget(card, 1)
root.addWidget(content, 1)
return host
def _build_drawer(
application: QApplication,
size: tuple[int, int],
state: str,
) -> tuple[QWidget, AppointmentDrawer]:
host = _background(size)
host.show()
repository = _ScreenshotRepository(
doctors=state != "empty_doctors",
rosters=state != "empty_roster",
slot_error=state == "error",
)
deferred = _DeferredAsync() if state == "loading" else None
drawer = AppointmentDrawer(
{
"id": 501,
"diagnosis_id": 501,
"patient_id": 999,
"patient_name": "林晓岚",
"doctor_id": 77,
},
repository=repository,
parent=host,
async_runner=deferred or _immediate_async,
)
drawer.show()
for _ in range(6):
application.processEvents()
assert drawer.drawer_width == round(size[0] * 0.60)
assert drawer.footer.geometry().bottom() == drawer.panel.rect().bottom()
assert drawer.ok_button.text() == "确定"
if state == "loading":
assert deferred is not None and len(deferred.calls) == 1
assert drawer._active_loading == "initial"
assert drawer.loading_overlay.isVisible()
assert drawer.loading_overlay.label.text() == "正在加载医生、渠道与挂号状态…"
assert not drawer.ok_button.isEnabled()
return host, drawer
if state == "empty_doctors":
assert not drawer.doctor_buttons
assert drawer.doctor_empty.text() == "暂无可预约医生"
assert drawer.time_empty.label.text() == "暂无可预约医生"
assert drawer.time_empty.illustration.size() == QSize(80, 60)
assert not drawer.ok_button.isEnabled()
return host, drawer
if state == "empty_roster":
assert drawer.doctor_buttons
assert not drawer.date_buttons
assert drawer.time_empty.label.text() == "该医生暂无排班"
assert drawer.time_empty.illustration.size() == QSize(80, 60)
assert not drawer.ok_button.isEnabled()
return host, drawer
if state == "error":
assert drawer.banner.property("kind") == "danger"
assert "号源加载失败" in drawer.banner.label.text()
assert drawer.slot_empty.label.text() == "号源加载失败"
assert not drawer.slot_buttons
assert not drawer.ok_button.isEnabled()
return host, drawer
assert [button.text() for button in drawer.doctor_buttons.values()] == [
"陈医生",
"周医生",
"林医生",
"宋医生",
]
assert all(button.size() == QSize(130, 40) for button in drawer.date_buttons.values())
future_date = next(
value for value in drawer.date_buttons if value > QDate.currentDate().toString("yyyy-MM-dd")
)
if drawer.date_combo.currentData() != future_date:
drawer.date_buttons[future_date].click()
application.processEvents()
drawer.channel_source.setCurrentIndex(drawer.channel_source.findData("online"))
first_available = next(button for button in drawer.slot_buttons.values() if button.isEnabled())
first_available.click()
drawer.remark.setPlainText("复诊预约,请医生提前查看近期睡眠记录。")
drawer._update_slot_scroll_height()
for _ in range(4):
application.processEvents()
unavailable = next(button for button in drawer.slot_buttons.values() if not button.isEnabled())
assert unavailable.status_label.text() == "已约"
assert unavailable.status_label.isVisible()
if state == "refreshing":
deferred = _DeferredAsync()
drawer._run_async = deferred
drawer.refresh_slots_button.click()
for _ in range(3):
application.processEvents()
assert len(deferred.calls) == 1
assert drawer.loading_overlay.isVisible()
assert drawer.loading_overlay.label.text() == "正在刷新可用号源…"
assert drawer.refresh_slots_button.text() == "刷新中…"
assert not drawer.refresh_slots_button.isEnabled()
assert drawer.slot_empty.label.text() == "正在刷新可用号源…"
elif state == "keyboard_focus":
focus_target = next(
button for button in drawer.date_buttons.values() if not button.isChecked()
)
drawer.activateWindow()
focus_target.setFocus(Qt.FocusReason.TabFocusReason)
application.processEvents()
assert focus_target.hasFocus()
assert application.focusWidget() is focus_target
else:
assert state == "default"
assert drawer.ok_button.isEnabled()
return host, drawer
def _render(application: QApplication, size: tuple[int, int], *, state: str = "default") -> Path:
host, drawer = _build_drawer(application, size, state)
canvas = QPixmap(*size)
canvas.fill(QColor("#F5F7FA"))
painter = QPainter(canvas)
origin = QPoint(0, 0)
host.render(painter, origin)
drawer.render(painter, origin)
painter.end()
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
state_suffix = "" if state == "default" else f"_{state}"
path = OUTPUT_DIR / f"appointment_drawer{state_suffix}_{size[0]}x{size[1]}.png"
if not canvas.save(str(path), "PNG"):
raise RuntimeError(f"Could not save {path}")
image = QImage(str(path))
rendered_size = (image.width(), image.height())
if rendered_size != size:
raise RuntimeError(f"Unexpected image size for {path}: {rendered_size}")
drawer.close()
host.close()
application.processEvents()
return path
def _load_cjk_font(application: QApplication) -> None:
candidates = (
Path(r"C:\Windows\Fonts\msyh.ttc"),
Path(r"C:\Windows\Fonts\simsun.ttc"),
Path("/System/Library/Fonts/PingFang.ttc"),
Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"),
)
for candidate in candidates:
if not candidate.exists():
continue
font_id = QFontDatabase.addApplicationFont(str(candidate))
families = QFontDatabase.applicationFontFamilies(font_id) if font_id >= 0 else []
if families:
application.setFont(QFont(families[0], 10))
return
application.setFont(QFont("sans-serif", 10))
def main() -> None:
application = QApplication.instance() or QApplication([])
application.setStyle("Fusion")
_load_cjk_font(application)
for size in ((1024, 640), (1440, 900)):
path = _render(application, size)
print(path.relative_to(ROOT))
for state in (
"empty_doctors",
"empty_roster",
"loading",
"refreshing",
"error",
"keyboard_focus",
):
path = _render(application, (1440, 900), state=state)
print(path.relative_to(ROOT))
if __name__ == "__main__":
main()
@@ -0,0 +1,694 @@
"""Render deterministic diagnosis readonly, edit, and view-only references."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtGui import QFont, QFontDatabase
from PySide6.QtWidgets import QApplication
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.diagnosis_editors import DailyRecordEditorDialog
from doctor_workstation.ui.diagnosis_media import RecordingPlayerDialog
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog
from doctor_workstation.ui.dialogs.prescription import PrescriptionEditorDialog
def _detail(*, locked: bool) -> dict[str, Any]:
diagnosis: dict[str, Any] = {
"id": 501,
"patient_id": 1501,
"patient_name": "林晓岚",
"phone": "13800138000",
"id_card": "110105199203071234",
"gender": 0,
"age": 34,
"marital_status": 1,
"height": 162,
"weight": 54.5,
"region": "浙江省杭州市",
"systolic_pressure": 146,
"diastolic_pressure": 92,
"fasting_blood_sugar": 8.2,
"diagnosis_type": "follow_up",
"diagnosis_date": "2026-08-10",
"status_desc": "诊疗中",
"channel_name": "健康顾问转介",
"statistical_visit_card": "第 4 次",
"current_medications": "二甲双胍",
"diabetes_discovery_year": "2019 年",
"local_hospital_diagnosis": ["2 型糖尿病", "高血压"],
"local_hospital_name": "杭州市第一人民医院",
"oral_condition": "口干",
"water_intake": "约 2200 ml",
"weight_change": "近月下降 1 kg",
"fatty_liver_degree": "轻度",
"diet_condition": ["偏甜", "夜宵"],
"body_feeling": ["乏力", "四肢沉重"],
"sleep_condition": "入睡稍慢",
"eye_condition": "偶有干涩",
"head_feeling": "午后头昏",
"sweat_condition": "易出汗",
"skin_condition": "皮肤偏干",
"urine_condition": "夜尿 2 次",
"stool_condition": "每日一次",
"kidney_condition": "腰酸",
"present_illness": "口渴、乏力反复半年,近期血糖波动。",
"past_history": "高血压病史 5 年。",
"trauma_history": 0,
"surgery_history": 0,
"allergy_history": 1,
"family_history": 1,
"pregnancy_history": 0,
"remark": "建议持续记录空腹及餐后血糖。",
"clinical_diagnosis": "气阴两虚",
"prescription_opinion": "益气养阴,兼顾活血。",
"has_prescription": 1,
"unserved_days": 4,
"patient_basic_locked": locked,
"can_edit_patient_basic": not locked,
"latest_prescription_order": {
"id": "RX-240810-09",
"fulfillment_status_text": "待配药",
},
}
return {
"diagnosis": diagnosis,
"patient": {
"id": 1501,
"patient_name": "林晓岚",
"phone": "13800138000",
"id_card": "110105199203071234",
"gender": 0,
"age": 34,
},
"appointment": {
"id": 2501,
"appointment_date": "2026-08-10",
"appointment_time": "09:00-09:30",
"period_text": "上午",
"doctor_name": "陈医生",
"assistant_name": "赵医助",
"status_desc": "已到诊",
"remark": "复诊评估",
},
}
class ScreenshotRepository:
"""No-network data source for visual artifact generation."""
def __init__(self, *, locked: bool) -> None:
self.detail = _detail(locked=locked)
def get_diagnosis_detail(self, diagnosis_id: int, **_kwargs: Any) -> dict[str, Any]:
if diagnosis_id != 501:
raise LookupError(diagnosis_id)
return self.detail
diagnosis_readonly_detail = get_diagnosis_detail
patient_detail = get_diagnosis_detail
def get_dictionary(self, dictionary_type: str) -> list[dict[str, Any]]:
if dictionary_type == "diagnosis_type":
return [
{"name": "初诊", "value": "first_visit"},
{"name": "复诊", "value": "follow_up"},
{"name": "会诊", "value": "consultation"},
]
if dictionary_type == "appetite":
return [
{"name": "口干", "value": "口干"},
{"name": "口苦", "value": "口苦"},
]
return []
def get_doctor_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
if diagnosis_id != 501:
raise LookupError(diagnosis_id)
return [
{
"id": 7001,
"create_time": "2026-08-10 09:20",
"doctor_name": "陈医生",
"content": "面色稍淡,舌淡红,苔薄白。",
"tongue_images": [{"url": "tongue.jpg"}],
"report_files": [
{
"name": "近期血糖趋势.pdf",
"url": "https://media.example.invalid/report.pdf",
}
],
}
]
def get_tracking_window(
self,
diagnosis_id: int,
*,
start_date: str = "",
end_date: str = "",
) -> dict[str, Any]:
if diagnosis_id != 501:
raise LookupError(diagnosis_id)
del start_date, end_date
return {
"blood_records": [
{
"id": 6101,
"diagnosis_id": 501,
"patient_id": 1501,
"record_date": "2026-08-10",
"fasting_blood_sugar": 8.2,
"systolic_pressure": 146,
"source": 1,
},
{
"id": 6102,
"diagnosis_id": 501,
"patient_id": 1501,
"record_date": "2026-08-10",
"postprandial_blood_sugar": 12.4,
"diastolic_pressure": 92,
"western_medicine": "二甲双胍",
},
{
"id": 6103,
"diagnosis_id": 501,
"patient_id": 1501,
"record_date": "2026-08-09",
"fasting_blood_sugar": 7.6,
"postprandial_blood_sugar": 10.8,
},
],
"diet_records": [
{
"id": 6201,
"diagnosis_id": 501,
"patient_id": 1501,
"record_date": "2026-08-10",
"breakfast_foods": "燕麦、鸡蛋",
"lunch_foods": "杂粮饭",
}
],
"exercise_records": [
{
"id": 6301,
"diagnosis_id": 501,
"patient_id": 1501,
"record_date": "2026-08-09",
"exercise_type": "散步",
"duration": 35,
"intensity": 2,
}
],
}
def list_tracking_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
if diagnosis_id != 501:
raise LookupError(diagnosis_id)
return [{"note_date": "2026-08-10", "content": "饭后散步,继续观察。"}]
def list_diagnosis_todos(self, diagnosis_id: int, **_kwargs: Any) -> dict[str, Any]:
if diagnosis_id != 501:
raise LookupError(diagnosis_id)
return {
"lists": [
{
"remind_time_text": "2026-08-12 09:00",
"content": "回访复测餐后血糖",
"status": 0,
"status_text": "待执行",
"creator_name": "陈医生",
"can_cancel": True,
"id": 7101,
}
],
"count": 1,
}
def list_prescriptions_by_diagnosis(self, diagnosis_id: int) -> list[dict[str, Any]]:
if diagnosis_id != 501:
raise LookupError(diagnosis_id)
return [
{
"id": 901,
"diagnosis_id": 501,
"prescription_date": "2026-08-02",
"global_visit_sequence": 4,
"prescription_type_text": "中药",
"prescription_summary": "玉泉丸加减",
"doctor_name": "陈医生",
"status_text": "已审核",
}
]
def appointment_history(self, **_kwargs: Any) -> dict[str, Any]:
return {
"lists": [
{
"id": 2501,
"status_desc": "已到诊",
"patient_name": "林晓岚",
"patient_phone": "13800138000",
"doctor_name": "陈医生",
"assistant_name": "赵医助",
"appointment_date": "2026-08-10",
"appointment_time": "09:00-09:30",
"appointment_type_text": "复诊",
"channel_name": "健康顾问",
"is_confirmed": 1,
"has_prescription": 1,
"remark": "复诊评估",
"create_time": "2026-08-08 11:20",
}
]
}
def assign_history(self, **_kwargs: Any) -> dict[str, Any]:
return {
"lists": [
{
"create_time": "2026-08-08 11:22",
"from_assistant_name": "王医助",
"to_assistant_name": "赵医助",
"is_inherit": 1,
"snapshot_order_creator_name": "王医助",
"snapshot_order_create_time": "2026-08-01 10:00",
"operator_name": "陈医生",
"operator_account": "doctor.chen",
"ip": "10.1.2.8",
}
]
}
def list_prescription_orders(self, **_kwargs: Any) -> dict[str, Any]:
return {
"lists": [
{
"id": 801,
"order_no": "RX-240810-09",
"global_visit_sequence": 4,
"statistics_count": 1,
"amount": "¥ 286.00",
"doctor_name": "陈医生",
"assistant_name": "赵医助",
"fulfillment_status_text": "待配药",
"create_time": "2026-08-10 09:24",
}
],
"count": 1,
}
def get_prescription_order(self, order_id: int) -> dict[str, Any]:
if order_id != 801:
raise LookupError(order_id)
return {
"id": order_id,
"order_no": "RX-240810-09",
"patient_name": "林晓岚",
"amount": 286,
"fulfillment_status_text": "待配药",
}
def add_blood_record(self, payload: dict[str, Any]) -> dict[str, Any]:
return {"id": 7201, **payload}
def add_diet_record(self, payload: dict[str, Any]) -> dict[str, Any]:
return {"id": 7202, **payload}
def add_exercise_record(self, payload: dict[str, Any]) -> dict[str, Any]:
return {"id": 7203, **payload}
def update_blood_record(
self, record: dict[str, Any], changes: dict[str, Any] | None = None
) -> dict[str, Any]:
return {**record, **(changes or {})}
def update_diet_record(
self, record: dict[str, Any], changes: dict[str, Any] | None = None
) -> dict[str, Any]:
return {**record, **(changes or {})}
def update_exercise_record(
self, record: dict[str, Any], changes: dict[str, Any] | None = None
) -> dict[str, Any]:
return {**record, **(changes or {})}
def add_tracking_note(self, diagnosis_id: int, content: str) -> dict[str, Any]:
return {"id": 7204, "diagnosis_id": diagnosis_id, "content": content}
def add_diagnosis_todo(
self, diagnosis_id: int, content: str, remind_time: int
) -> dict[str, Any]:
return {
"id": 7205,
"diagnosis_id": diagnosis_id,
"content": content,
"remind_time": remind_time,
}
def cancel_diagnosis_todo(self, todo_id: int) -> dict[str, Any]:
return {"id": todo_id, "status": 2}
def add_doctor_note(self, diagnosis_id: int, content: str = "", **_kwargs: Any) -> Any:
return {"id": 7206, "diagnosis_id": diagnosis_id, "content": content}
def delete_doctor_note_image(self, note_id: int, image_type: str, image_path: str) -> Any:
return {"note_id": note_id, "image_type": image_type, "image_path": image_path}
def upload_material(self, path: str, material_type: str) -> str:
return f"https://media.example.invalid/{material_type}/{Path(path).name}"
def create_prescription(self, prescription: dict[str, Any]) -> Any:
return {"id": 7207, **prescription}
def set_revisit_slot_start_offset(self, diagnosis_id: int, offset: int) -> Any:
return {"id": diagnosis_id, "revisit_slot_start_offset": offset}
def list_call_records(self, diagnosis_id: int) -> list[dict[str, Any]]:
return [
{
"id": 7208,
"diagnosis_id": diagnosis_id,
"recording_urls_list": [
"https://media.example.invalid/replay.mp4",
"https://media.example.invalid/replay-backup.m3u8",
"https://media.example.invalid/replay.webm",
],
"start_time_text": "2026-08-10 09:10:00",
"end_time_text": "2026-08-10 09:22:00",
"call_type_text": "视频",
"room_id": "demo-room-501",
"duration_text": "12分00秒",
"status_text": "已结束",
"recording_status_text": "录制完成",
}
]
def upload_call_recording(
self, path: str, diagnosis_id: int, *, call_record_id: int | None = None
) -> dict[str, Any]:
return {
"diagnosis_id": diagnosis_id,
"call_record_id": call_record_id,
"file_url": Path(path).name,
}
def list_im_chat_messages(
self, diagnosis_id: int, *, only_archived: bool = True
) -> dict[str, Any]:
del diagnosis_id
return {
"only_archived": only_archived,
"lists": [
{
"msg_id": "chat-1",
"msg_type": "text",
"text": "今天空腹血糖 5.8。",
"is_from_doctor": False,
"time": "2026-08-10 08:30",
},
{
"msg_id": "chat-2",
"msg_type": "image",
"image_url": "https://media.example.invalid/blood.jpg",
"is_from_doctor": False,
"time": "2026-08-10 08:31",
},
{
"msg_id": "chat-3",
"msg_type": "file",
"file_url": "https://media.example.invalid/report.pdf",
"file_name": "复查报告.pdf",
"is_from_doctor": True,
"from_staff_name": "陈医生",
"time": "2026-08-10 08:40",
},
],
}
def sync_im_chat_messages(self, diagnosis_id: int) -> dict[str, Any]:
return {"diagnosis_id": diagnosis_id, "queued": True}
class EmptyScreenshotRepository(ScreenshotRepository):
def get_doctor_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
del diagnosis_id
return []
def list_prescriptions_by_diagnosis(self, diagnosis_id: int) -> list[dict[str, Any]]:
del diagnosis_id
return []
def list_prescription_orders(self, **_kwargs: Any) -> dict[str, Any]:
return {"lists": [], "count": 0}
class FailingScreenshotRepository(ScreenshotRepository):
def get_diagnosis_detail(self, diagnosis_id: int, **_kwargs: Any) -> dict[str, Any]:
del diagnosis_id
raise RuntimeError("诊单详情加载失败,请检查网络后重试")
def _run_immediately(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**_kwargs: Any,
) -> object:
try:
result = function(*args)
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
def render() -> list[Path]:
app = QApplication.instance() or QApplication([])
font_path = Path("C:/Windows/Fonts/msyh.ttc")
if font_path.is_file():
font_id = QFontDatabase.addApplicationFont(str(font_path))
families = QFontDatabase.applicationFontFamilies(font_id)
if families:
app.setFont(QFont(families[0], 9))
diagnosis_module.run_async = _run_immediately
root = Path(__file__).resolve().parents[1]
output = root / "artifacts" / "diagnosis_visual"
output.mkdir(parents=True, exist_ok=True)
rendered: list[Path] = []
for width, height in ((1024, 640), (1440, 900)):
for mode in ("readonly", "edit", "viewonly"):
dialog = DiagnosisDialog(
ScreenshotRepository(locked=mode == "viewonly"),
permissions=PermissionSet(["*"]),
)
dialog.resize(width, height)
if mode == "readonly":
dialog.open_for(501, editable=False)
elif mode == "edit":
dialog.open_for(501, editable=True)
else:
dialog.open_view_only(501)
for _ in range(8):
app.processEvents()
if mode == "readonly":
dialog.readonly_scroll.verticalScrollBar().setValue(0)
else:
dialog.tabs.setCurrentIndex(0)
app.processEvents()
path = output / f"diagnosis_{mode}_{width}x{height}.png"
if not dialog.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
rendered.append(path)
dialog.close()
app.processEvents()
def save_state(dialog: DiagnosisDialog, name: str, *, close: bool = True) -> None:
for _ in range(8):
app.processEvents()
path = output / f"diagnosis_state_{name}_1024x640.png"
if not dialog.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
rendered.append(path)
if close:
dialog.close()
app.processEvents()
def save_widget(widget: Any, name: str) -> None:
widget.show()
for _ in range(6):
app.processEvents()
path = output / f"diagnosis_state_{name}.png"
if not widget.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
rendered.append(path)
widget.close()
app.processEvents()
pending: list[dict[str, Any]] = []
def queue_async(function: Any, **options: Any) -> object:
pending.append({"function": function, **options})
return object()
diagnosis_module.run_async = queue_async
loading = DiagnosisDialog(ScreenshotRepository(locked=False), permissions=PermissionSet(["*"]))
loading.resize(1024, 640)
loading.open_for(501, editable=True)
save_state(loading, "loading")
diagnosis_module.run_async = _run_immediately
error = DiagnosisDialog(
FailingScreenshotRepository(locked=False), permissions=PermissionSet(["*"])
)
error.resize(1024, 640)
error.open_for(501, editable=True, seed=_detail(locked=False))
save_state(error, "error")
empty = DiagnosisDialog(
EmptyScreenshotRepository(locked=False), permissions=PermissionSet(["*"])
)
empty.resize(1024, 640)
empty.open_for(501, editable=True)
empty_index = next(
index
for index in range(empty.tabs.count())
if empty.tabs.tabBar().tabData(index) == "prescription"
)
empty.tabs.setCurrentIndex(empty_index)
save_state(empty, "empty")
permission = DiagnosisDialog(
ScreenshotRepository(locked=True),
permissions=PermissionSet(["tcm.diagnosis/readonlyDetail"]),
)
permission.resize(1024, 640)
permission.open_view_only(501)
save_state(permission, "permission")
daily = DiagnosisDialog(ScreenshotRepository(locked=False), permissions=PermissionSet(["*"]))
daily.resize(1024, 640)
daily.open_for(501, editable=True)
daily_index = next(
index
for index in range(daily.tabs.count())
if daily.tabs.tabBar().tabData(index) == "daily"
)
daily.tabs.setCurrentIndex(daily_index)
save_state(daily, "daily", close=False)
daily_page = daily._tab_pages["daily"]
daily_page.verticalScrollBar().setValue(daily_page.verticalScrollBar().maximum())
save_state(daily, "daily_lower")
focus = DiagnosisDialog(ScreenshotRepository(locked=False), permissions=PermissionSet(["*"]))
focus.resize(1024, 640)
focus.open_for(501, editable=True)
focus.edit_fields["patient_name"].setFocus()
save_state(focus, "focus")
save_states = DiagnosisDialog(
ScreenshotRepository(locked=False), permissions=PermissionSet(["*"])
)
save_states.resize(1024, 640)
save_states.open_for(501, editable=True)
save_states.save_button.set_state("loading")
save_state(save_states, "save_loading", close=False)
save_states.save_button.set_state("success")
save_state(save_states, "save_success", close=False)
save_states.save_button.set_state("error")
save_state(save_states, "save_failure")
for tab_key, state_name in (
("notes", "notes_actions"),
("video", "video_replay"),
("chat", "chat_archive"),
("orders", "order_offset"),
):
dialog = DiagnosisDialog(
ScreenshotRepository(locked=False), permissions=PermissionSet(["*"])
)
dialog.resize(1024, 640)
dialog.open_for(501, editable=True)
index = next(
item
for item in range(dialog.tabs.count())
if dialog.tabs.tabBar().tabData(item) == tab_key
)
dialog.tabs.setCurrentIndex(index)
save_state(dialog, state_name, close=False)
if tab_key == "orders":
order_detail = dialog._build_order_detail_dialog(
dialog.repository.get_prescription_order(801), 801
)
save_widget(order_detail, "order_detail_640x540")
elif tab_key == "video":
video_table = dialog._table_registry["video"][1]
video_table.horizontalScrollBar().setValue(video_table.horizontalScrollBar().maximum())
save_state(dialog, "video_upload_action", close=False)
player = RecordingPlayerDialog(
"https://media.example.invalid/replay.mp4", parent=dialog
)
save_widget(player, "video_player_820x560")
dialog.close()
app.processEvents()
daily_editor = DailyRecordEditorDialog(
"blood",
{
"id": 6101,
"diagnosis_id": 501,
"patient_id": 1501,
"record_date": "2026-08-10",
"record_time": "08:20",
"fasting_blood_sugar": 8.2,
"postprandial_blood_sugar": 12.4,
"systolic_pressure": 146,
"diastolic_pressure": 92,
"western_medicine": "二甲双胍",
"remark": "继续观察餐后波动。",
},
)
daily_editor.setStyleSheet(diagnosis_module.DIAGNOSIS_QSS)
save_widget(daily_editor, "daily_blood_edit_650x620")
prescription = PrescriptionEditorDialog(
ScreenshotRepository(locked=False),
{
"diagnosis_id": 501,
"appointment_id": 2501,
"patient_name": "林晓岚",
"gender": 0,
"age": 34,
"visit_no": "1K00002501",
"tongue": "舌淡红、苔薄白",
"pulse": "脉细",
"clinical_diagnosis": "气阴两虚",
"doctor_name": "陈医生",
},
mode="add",
current_user={"id": 1, "name": "陈医生"},
)
prescription.setObjectName("DiagnosisPrescriptionEditor")
prescription.setStyleSheet(diagnosis_module.DIAGNOSIS_QSS)
save_widget(prescription, "prescription_editor_920x780")
return rendered
if __name__ == "__main__":
for path in render():
print(path)
@@ -0,0 +1,158 @@
"""Render the Notes, Chat, and Daily-lower parity states without external network."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QBuffer, QIODevice, QPointF, Qt, QTimer
from PySide6.QtGui import QColor, QFont, QFontDatabase, QImage, QPainter, QPen
from PySide6.QtWidgets import QApplication
from render_diagnosis_detail_visual import ScreenshotRepository, _run_immediately
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui import diagnosis_drawer
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog
class MediaScreenshotRepository(ScreenshotRepository):
"""Use the established artifact fixture with production-shaped media URLs."""
def get_doctor_notes(self, diagnosis_id: int) -> list[dict[str, Any]]:
notes = super().get_doctor_notes(diagnosis_id)
notes[0]["tongue_images"] = [
{"url": "https://media.example.invalid/diagnosis/501/tongue-7001.jpg"}
]
return notes
def _image_bytes(kind: str) -> bytes:
if kind == "tongue":
image = QImage(240, 180, QImage.Format.Format_ARGB32)
image.fill(QColor("#F4E3D8"))
painter = QPainter(image)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(QPen(QColor("#D1A08F"), 3))
painter.setBrush(QColor("#D77F83"))
painter.drawEllipse(52, 18, 136, 162)
painter.setPen(QPen(QColor("#B75F65"), 2))
painter.drawLine(120, 42, 120, 148)
painter.setPen(QPen(QColor("#F7D8D2"), 10, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
painter.drawLine(91, 55, 103, 132)
painter.drawLine(149, 55, 137, 132)
painter.end()
else:
image = QImage(640, 360, QImage.Format.Format_ARGB32)
image.fill(QColor("#F8FAFC"))
painter = QPainter(image)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(QPen(QColor("#DCE3EC"), 1))
for x in range(48, 610, 70):
painter.drawLine(x, 36, x, 316)
for y in range(56, 310, 52):
painter.drawLine(42, y, 606, y)
points = [
QPointF(48, 250),
QPointF(125, 226),
QPointF(205, 238),
QPointF(285, 168),
QPointF(365, 190),
QPointF(445, 112),
QPointF(525, 142),
QPointF(602, 82),
]
painter.setPen(QPen(QColor("#0F766E"), 7, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
for start, end in zip(points, points[1:], strict=False):
painter.drawLine(start, end)
painter.setPen(QPen(QColor("#FFFFFF"), 3))
painter.setBrush(QColor("#0F766E"))
for point in points:
painter.drawEllipse(point, 8, 8)
painter.end()
buffer = QBuffer()
if not buffer.open(QIODevice.OpenModeFlag.WriteOnly) or not image.save(buffer, "PNG"):
raise RuntimeError("failed to encode deterministic visual image")
return bytes(buffer.data())
TONGUE_IMAGE = _image_bytes("tongue")
CHAT_IMAGE = _image_bytes("chat")
def _offline_remote_load(self: diagnosis_drawer._RemoteImageButton, source: str) -> None:
"""Preserve asynchronous completion while replacing only external transport."""
self._source = str(source).strip()
generation = self._invalidate_request()
self.setToolTip(self._source)
self._show_loading()
payload = TONGUE_IMAGE if self.objectName() == "DiagnosisTongueThumb" else CHAT_IMAGE
QTimer.singleShot(0, lambda: self._apply_payload(payload, generation))
def _prepare_app() -> QApplication:
app = QApplication.instance() or QApplication([])
app.setFont(QFont("Microsoft YaHei", 9))
font_path = Path("C:/Windows/Fonts/msyh.ttc")
if font_path.is_file():
font_id = QFontDatabase.addApplicationFont(str(font_path))
families = QFontDatabase.applicationFontFamilies(font_id)
if families:
app.setFont(QFont(families[0], 9))
return app
def _open_tab(app: QApplication, key: str) -> DiagnosisDialog:
dialog = DiagnosisDialog(
MediaScreenshotRepository(locked=False),
permissions=PermissionSet(["*"]),
)
dialog.resize(1024, 640)
dialog.open_for(501, editable=True)
index = next(
item for item in range(dialog.tabs.count()) if dialog.tabs.tabBar().tabData(item) == key
)
dialog.tabs.setCurrentIndex(index)
for _ in range(10):
app.processEvents()
return dialog
def render() -> list[Path]:
app = _prepare_app()
diagnosis_module.run_async = _run_immediately
diagnosis_drawer._RemoteImageButton.load_url = _offline_remote_load
output = Path(__file__).resolve().parents[1] / "artifacts" / "diagnosis_visual"
output.mkdir(parents=True, exist_ok=True)
rendered: list[Path] = []
for tab_key, state_name in (("notes", "notes_actions"), ("chat", "chat_archive")):
dialog = _open_tab(app, tab_key)
path = output / f"diagnosis_state_{state_name}_1024x640.png"
if not dialog.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
rendered.append(path)
dialog.close()
app.processEvents()
daily = _open_tab(app, "daily")
page = daily._tab_pages["daily"]
page.verticalScrollBar().setValue(page.verticalScrollBar().maximum())
for _ in range(4):
app.processEvents()
daily_path = output / "diagnosis_state_daily_lower_1024x640.png"
if not daily.grab().save(str(daily_path), "PNG"):
raise RuntimeError(f"failed to save {daily_path}")
rendered.append(daily_path)
daily.close()
app.processEvents()
return rendered
if __name__ == "__main__":
for rendered_path in render():
print(rendered_path)
@@ -0,0 +1,348 @@
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
from PySide6.QtCore import Qt # noqa: E402
from PySide6.QtGui import QFont, QFontDatabase # noqa: E402
from PySide6.QtWidgets import QApplication, QFrame, QLabel, QVBoxLayout # noqa: E402
from doctor_workstation.ui.diagnosis_drawer import DIAGNOSIS_QSS # noqa: E402
from doctor_workstation.ui.diagnosis_media import ( # noqa: E402
InlineRecordingPlayer,
RecordingPlaybackCell,
)
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog # noqa: E402
class RenderRepository:
def get_prescription_order(self, order_id: int) -> dict[str, Any]:
return {"id": order_id}
def set_revisit_slot_start_offset(self, diagnosis_id: int, offset: int) -> dict[str, Any]:
return {"diagnosis_id": diagnosis_id, "revisit_slot_start_offset": offset}
def upload_call_recording(
self,
path: str,
diagnosis_id: int,
*,
call_record_id: int | None = None,
) -> dict[str, Any]:
return {
"path": path,
"diagnosis_id": diagnosis_id,
"call_record_id": call_record_id,
}
def order_detail() -> dict[str, Any]:
return {
"id": 801,
"order_no": "RX-20260811-0801",
"gancao_reciperl_order_no": "GC-260811-4728",
"diagnosis_id": 501,
"amount": 428.5,
"linked_pay_paid_total": 300,
"refund_amount": 20,
"agency_collect_amount": 128.5,
"fulfillment_status": 5,
"prescription_audit_status": 1,
"payment_slip_audit_status": 1,
"doctor_name": "陈医生",
"creator_name": "赵医助",
"creator_account": "assistant.zhao",
"create_time": "2026-08-11 09:26",
"recipient_name": "林晓岚",
"recipient_phone": "18600004218",
"shipping_province": "河南省",
"shipping_city": "洛阳市",
"shipping_district": "洛龙区",
"shipping_address": "开元大道 88 号",
"is_follow_up": 1,
"medication_days": 14,
"service_channel": "线上复诊",
"service_package": ["调理服务", "复诊随访"],
"fee_type": 3,
"tracking_number": "SF164208110801",
"express_company": "sf",
"remark_assistant": "工作日下午送达",
"prescription_audit_remark": "辨证与用量已复核",
"payment_slip_audit_remark": "收款凭证已核验",
"prescription": {
"id": 601,
"sn": "RX601",
"patient_name": "林晓岚",
"gender_desc": "",
"age": 34,
"phone": "18600004218",
"prescription_date": "2026-08-11",
"doctor_name": "陈医生",
"prescription_type": "饮片",
"clinical_diagnosis": "气阴两虚",
"dose_count": 14,
"dose_unit": "",
"usage_instruction": "水煎服",
"amount": 428.5,
"audit_status": 1,
"dosage_amount": 180,
"dosage_unit": "g",
"need_decoction": 1,
"times_per_day": 2,
"usage_days": 14,
"dietary_taboo": ["辛辣", "生冷"],
"void_status": 0,
},
"linked_pay_orders": [
{
"id": 9101,
"order_no": "PAY-9101",
"order_type_desc": "药品费用",
"amount": 300,
"status_desc": "已支付",
"creator_name": "赵医助",
"create_time": "2026-08-11 09:32",
}
],
"unlinked_pay_orders": [],
"logistics_trace": {
"state_text": "运输中",
"carrier_label": "顺丰速运",
"traces": [
{
"time": "2026-08-11 16:10",
"status": "运输中",
"context": "快件已离开洛阳集散中心",
},
{
"time": "2026-08-11 13:06",
"status": "已揽收",
"context": "顺丰速运已收取快件",
},
],
},
"logs": [
{
"admin_name": "赵医助",
"action": "ship",
"summary": "确认发货并填写顺丰运单",
"create_time": "2026-08-11 13:08",
}
],
}
def render_order(app: QApplication, output: Path) -> None:
host = DiagnosisDialog(RenderRepository(), permissions=["*"])
host.resize(1440, 900)
host.show()
drawer = host._build_order_detail_dialog(order_detail(), 801)
drawer.show()
app.processEvents()
image = drawer.grab()
if not image.save(str(output), "PNG"):
raise RuntimeError(f"failed to save {output}")
host.resize(1024, 640)
drawer.sync_to_owner()
app.processEvents()
compact = output.parent / "diagnosis_state_order_detail_drawer_1024x640.png"
if not drawer.grab().save(str(compact), "PNG"):
raise RuntimeError(f"failed to save {compact}")
legacy = output.parent / "diagnosis_state_order_detail_640x540.png"
if not drawer.grab().save(str(legacy), "PNG"):
raise RuntimeError(f"failed to save {legacy}")
drawer.close()
host.close()
app.processEvents()
def render_video(app: QApplication, output: Path) -> None:
dialog = DiagnosisDialog(RenderRepository(), permissions=["*"])
dialog._editable = True
dialog._can_video_upload = True
dialog._diagnosis_id = 501
dialog._tab_generations["video"] = 3
dialog._fill_video(
[
{
"id": 48,
"recording_urls_list": [
"https://media.example.invalid/consultation-48.mp4",
"https://bucket.cos.ap-shanghai.myqcloud.com/consultation-48/index.m3u8",
"https://media.example.invalid/consultation-48.webm",
],
"start_time_text": "2026-08-11 10:02:18",
"end_time_text": "2026-08-11 10:26:43",
"call_type": 2,
"room_id": "room-501-20260811",
"duration_text": "24分25秒",
"status": 2,
"recording_status_text": "录制完成",
},
{
"id": 47,
"recording_urls_list": [],
"start_time_text": "2026-08-04 09:18:05",
"end_time_text": "2026-08-04 09:23:17",
"call_type": 1,
"room_id": "room-501-20260804",
"duration_text": "5分12秒",
"status": 3,
"recording_status_text": "暂无录制",
},
]
)
preview = QFrame()
preview.setObjectName("DiagnosisVideoRender")
preview.setStyleSheet(
DIAGNOSIS_QSS
+ "QFrame#DiagnosisVideoRender{background:#F6F8FB;}"
+ "QLabel#DiagnosisVideoRenderTitle{color:#1F2937;font-size:20px;font-weight:650;}"
)
layout = QVBoxLayout(preview)
layout.setContentsMargins(22, 18, 22, 22)
layout.setSpacing(12)
title = QLabel("视频录制回放")
title.setObjectName("DiagnosisVideoRenderTitle")
title.setFixedHeight(32)
title.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
layout.addWidget(title)
page = dialog._tab_pages["video"]
page.setParent(preview)
page.show()
dialog.video_upload_button.show()
layout.addWidget(page, 1)
preview.resize(1200, 560)
preview.show()
app.processEvents()
table = dialog._table_registry["video"][1]
playback = table.cellWidget(0, 0)
if not isinstance(playback, RecordingPlaybackCell):
raise RuntimeError("video render is missing the inline playback cell")
player = playback.findChild(InlineRecordingPlayer)
if (
player is None
or playback.height() < playback.minimumSizeHint().height()
or not 158 <= player.height() <= 180
or table.rowHeight(0) < playback.required_table_row_height()
):
raise RuntimeError("video render clipped the inline playback geometry")
image = preview.grab()
if not image.save(str(output), "PNG"):
raise RuntimeError(f"failed to save {output}")
preview.resize(1024, 640)
app.processEvents()
replay_state = output.parent / "diagnosis_state_video_replay_1024x640.png"
if not preview.grab().save(str(replay_state), "PNG"):
raise RuntimeError(f"failed to save {replay_state}")
table.horizontalScrollBar().setValue(table.horizontalScrollBar().maximum())
app.processEvents()
upload_state = output.parent / "diagnosis_state_video_upload_action_1024x640.png"
if not preview.grab().save(str(upload_state), "PNG"):
raise RuntimeError(f"failed to save {upload_state}")
preview.close()
dialog.close()
app.processEvents()
def render_order_offset(app: QApplication, output: Path) -> None:
dialog = DiagnosisDialog(RenderRepository(), permissions=["*"])
dialog._editable = True
dialog._can_offset = True
dialog._can_order_detail = True
dialog._diagnosis_id = 501
dialog._saved_order_offset = 0
dialog.order_offset.setVisible(True)
dialog.order_offset_save.setVisible(True)
dialog.order_offset.setValue(2)
dialog._fill_orders(
[
{
"id": 801,
"order_no": "RX-20260811-0801",
"global_visit_seq": 3,
"counts_for_revisit_rate": 1,
"amount": 428.5,
"doctor_name": "陈医生",
"assistant_name": "赵医助",
"fulfillment_status": 5,
"create_time": "2026-08-11 09:26",
},
{
"id": 794,
"order_no": "RX-20260724-0794",
"global_visit_seq": 2,
"counts_for_revisit_rate": 1,
"amount": 386,
"doctor_name": "陈医生",
"assistant_name": "赵医助",
"fulfillment_status": 6,
"create_time": "2026-07-24 10:18",
},
]
)
dialog._orders_total = 2
dialog._update_orders_pager()
preview = QFrame()
preview.setObjectName("DiagnosisOrderRender")
preview.setStyleSheet(
DIAGNOSIS_QSS
+ "QFrame#DiagnosisOrderRender{background:#F6F8FB;}"
+ "QLabel#DiagnosisOrderRenderTitle{color:#1F2937;font-size:20px;font-weight:650;}"
)
layout = QVBoxLayout(preview)
layout.setContentsMargins(22, 18, 22, 22)
layout.setSpacing(12)
title = QLabel("业务订单")
title.setObjectName("DiagnosisOrderRenderTitle")
title.setFixedHeight(32)
layout.addWidget(title)
page = dialog._tab_pages["orders"]
page.setParent(preview)
page.show()
layout.addWidget(page, 1)
preview.resize(1024, 640)
preview.show()
app.processEvents()
if not preview.grab().save(str(output), "PNG"):
raise RuntimeError(f"failed to save {output}")
preview.close()
dialog.close()
app.processEvents()
def main() -> int:
output_dir = ROOT / "artifacts" / "diagnosis_visual"
output_dir.mkdir(parents=True, exist_ok=True)
app = QApplication.instance() or QApplication([])
font_path = Path("C:/Windows/Fonts/msyh.ttc")
if font_path.is_file():
font_id = QFontDatabase.addApplicationFont(str(font_path))
families = QFontDatabase.applicationFontFamilies(font_id)
if families:
app.setFont(QFont(families[0], 9))
order_output = output_dir / "diagnosis_order_detail_drawer_1440x900.png"
video_output = output_dir / "diagnosis_video_inline_player_1200x560.png"
offset_output = output_dir / "diagnosis_state_order_offset_1024x640.png"
render_order(app, order_output)
render_video(app, video_output)
render_order_offset(app, offset_output)
print(order_output)
print(video_output)
print(offset_output)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,81 @@
"""Render the diagnosis list inside the real application shell."""
from __future__ import annotations
import os
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QThreadPool
from PySide6.QtGui import QFont, QFontDatabase
from PySide6.QtWidgets import QApplication
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui import ShellWindow, apply_theme
def render() -> list[Path]:
app = QApplication.instance() or QApplication([])
apply_theme(app)
font_path = Path("C:/Windows/Fonts/msyh.ttc")
if font_path.is_file():
font_id = QFontDatabase.addApplicationFont(str(font_path))
families = QFontDatabase.applicationFontFamilies(font_id)
if families:
app.setFont(QFont(families[0], 9))
root = Path(__file__).resolve().parents[1]
output = root / "artifacts" / "diagnosis_visual"
output.mkdir(parents=True, exist_ok=True)
paths: list[Path] = []
for width, height in ((1024, 640), (1440, 900)):
repository = DemoDoctorRepository()
session = repository.login(
repository.DEMO_ACCOUNT,
repository.DEMO_PASSWORD,
)
shell = ShellWindow(
repository,
{"session": session, "demo_mode": True},
permissions=session.permissions,
)
shell.resize(width, height)
shell.show()
# Exercise the real visited-page contract so the evidence includes
# a fixed landing tab, a closable visited tab, and the active diagnosis tab.
another_key = next(
(
key
for key in ("patients", "reception", "prescriptions")
if key in shell.pages and key != "consultations"
),
None,
)
if another_key is not None:
shell.navigate(another_key)
shell.navigate("consultations")
QThreadPool.globalInstance().waitForDone(3000)
for _ in range(10):
app.processEvents()
page = shell.pages["consultations"]
page.poll_timer.stop()
page.page_scroll.verticalScrollBar().setValue(0)
app.processEvents()
path = output / f"diagnosis_shell_{width}x{height}.png"
if not shell.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
paths.append(path)
shell.close()
app.processEvents()
return paths
if __name__ == "__main__":
for rendered in render():
print(rendered)
+465
View File
@@ -0,0 +1,465 @@
"""Render deterministic offscreen diagnosis-index reference screenshots."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import Qt, QThreadPool, Signal
from PySide6.QtGui import QColor, QFont, QFontDatabase, QImage, QPainter, QPixmap
from PySide6.QtWidgets import QApplication, QToolButton, QWidget
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.pages import consultations as consultations_module
from doctor_workstation.ui.pages.consultations import ConsultationsPage
class _ScreenshotDiagnosisDialog(QWidget):
"""Invisible list-rendering seam; no detail drawer is exercised in this script."""
saved = Signal()
def __init__(self, _repository: Any, parent: QWidget | None = None) -> None:
super().__init__(parent)
def open_for(self, *_args: Any, **_kwargs: Any) -> None:
return None
consultations_module.DiagnosisDialog = _ScreenshotDiagnosisDialog
def _row(identifier: int, variant: int) -> dict[str, Any]:
common: dict[str, Any] = {
"id": identifier,
"diagnosis_id": identifier,
"patient_id": identifier + 1000,
"patient_name": ("林晓岚", "周明远", "许安然", "沈知夏")[variant % 4],
"gender": 2 if variant % 2 else 1,
"age": 28 + variant,
"assistant_id": 8,
"assistant_name": "赵医助",
"assign_read_at": None if variant == 0 else "2026-08-10 08:30",
"DiagnosisViewRecord": [{"is_confirmed": 1}],
"has_appointment": 1,
"appointment_id": identifier + 2000,
"appointment_status": 1,
"appointment_doctor_id": 18,
"appointments": [
{
"id": identifier + 2000,
"status": 1,
"doctor_id": 18,
"doctor_name": "陈医生",
"time_text": "今天 09:00-09:30",
}
],
"latest_appointment_channel_text": "健康顾问转介",
"has_prescription": 1,
"prescription_audit_status": 1,
"prescription_void_status": 0,
"followup_time_text": "2026-08-17 09:00",
"followup_doctor_name": "陈医生",
"followup_rx_voided": 0,
"unserved_days": (1, 4, 8, 2)[variant % 4],
"last_blood_record_at": "2026-08-09 20:10",
"video_call_hint": "未在通话中",
}
if variant == 1:
common.update(
{
"has_appointment": 0,
"appointment_id": 0,
"appointment_status": None,
"appointments": [],
"DiagnosisViewRecord": [{"is_confirmed": 0}],
"assistant_id": 0,
"assistant_name": "",
"has_prescription": 0,
"followup_time_text": "",
"last_blood_record_at": "",
"unserved_days": None,
}
)
elif variant == 2:
common.update(
{
"appointment_status": 4,
"appointments": [
{
"id": identifier + 2000,
"status": 4,
"doctor_id": 18,
"doctor_name": "陈医生",
"time_text": "昨天 15:00-15:30",
},
{
"id": identifier + 2001,
"status": 3,
"doctor_id": 19,
"doctor_name": "李医生",
"time_text": "08-03 10:30-11:00",
},
],
"followup_rx_voided": 1,
}
)
return common
class ScreenshotRepository:
"""No-network repository used only by the visual renderer."""
def __init__(self) -> None:
self.rows = [_row(501 + index, index % 4) for index in range(15)]
def list_consultations(
self,
*,
page_no: int = 1,
page_size: int = 15,
**_filters: Any,
) -> dict[str, Any]:
start = max(0, page_no - 1) * page_size
return {"lists": self.rows[start : start + page_size], "count": 42}
def get_dictionary(self, dictionary_type: str) -> list[dict[str, Any]]:
values = {
"diagnosis_type": [("中医", "tcm"), ("中西医结合", "integrated")],
"syndrome_type": [("痰湿", "phlegm_damp"), ("气虚", "qi_deficiency")],
"channels": [("健康顾问", "advisor"), ("门诊", "clinic")],
}
return [{"name": name, "value": value} for name, value in values.get(dictionary_type, [])]
def list_diagnosis_assistants(self) -> list[dict[str, Any]]:
return [{"id": 8, "name": "赵医助"}, {"id": 9, "name": "王医助"}]
def cancel_diagnosis_appointment(self, appointment_id: int) -> None:
raise RuntimeError(f"visual fixture does not cancel appointment #{appointment_id}")
def generate_video_qrcode(
self, doctor_id: int, patient_id: int, share_user_id: int
) -> dict[str, str]:
raise RuntimeError(
f"visual fixture does not generate video QR: {doctor_id}/{patient_id}/{share_user_id}"
)
def generate_diagnosis_qrcode(
self,
diagnosis_id: int,
doctor_id: int,
patient_id: int,
share_user_id: int,
) -> dict[str, str]:
raise RuntimeError(
"visual fixture does not generate diagnosis QR: "
f"{diagnosis_id}/{doctor_id}/{patient_id}/{share_user_id}"
)
def list_appointment_logs(self, diagnosis_id: int) -> list[dict[str, Any]]:
raise RuntimeError(f"visual fixture does not load logs for diagnosis #{diagnosis_id}")
def create_diagnosis_order(
self,
patient_id: int,
order_type: int,
amount: float,
*,
remark: str = "",
) -> dict[str, Any]:
raise RuntimeError(
f"visual fixture does not create order: {patient_id}/{order_type}/{amount}/{remark}"
)
def generate_order_qrcode(self, order_no: str) -> dict[str, Any]:
raise RuntimeError(f"visual fixture does not generate payment QR: {order_no}")
# The list only checks these methods as a fail-closed capability contract;
# rendering never starts a call.
def get_call_ticket(self, patient_id: int, diagnosis_id: int) -> None:
raise RuntimeError(f"visual fixture does not issue tickets: {patient_id}/{diagnosis_id}")
def start_call(self, diagnosis_id: int, patient_id: int, *, call_type: int = 2) -> None:
raise RuntimeError(
f"visual fixture does not start calls: {diagnosis_id}/{patient_id}/{call_type}"
)
def bind_call_room(self, diagnosis_id: int, room_id: str) -> None:
raise RuntimeError(f"visual fixture does not bind rooms: {diagnosis_id}/{room_id}")
def end_call(self, diagnosis_id: int) -> None:
raise RuntimeError(f"visual fixture does not end calls: {diagnosis_id}")
def _settle(app: QApplication, page: ConsultationsPage) -> None:
QThreadPool.globalInstance().waitForDone(3000)
for _ in range(8):
app.processEvents()
page.poll_timer.stop()
page.loading_overlay.stop()
page.page_scroll.verticalScrollBar().setValue(0)
app.processEvents()
def _new_page(
app: QApplication,
repository: ScreenshotRepository,
width: int,
height: int,
*,
permissions: PermissionSet | None = None,
) -> ConsultationsPage:
page = ConsultationsPage(
repository,
permissions=permissions or PermissionSet(["*"]),
current_user={"id": 1, "name": "陈医生"},
)
page.resize(width, height)
page.show()
_settle(app, page)
return page
def _save(page: ConsultationsPage, path: Path) -> Path:
if not page.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
return path
def _save_with_menu(page: ConsultationsPage, path: Path) -> Path:
"""Composite the real QMenu popup over the real page grab for offscreen determinism."""
index = page.table_host.model.index(0, 11)
cell = page.table_host.fixed.indexWidget(index)
more = next(button for button in cell.findChildren(QToolButton) if button.menu())
menu = more.menu()
menu.ensurePolished()
menu.adjustSize()
menu.resize(menu.sizeHint())
menu.show()
QApplication.processEvents()
page_pixmap = page.grab()
menu_pixmap = menu.grab()
painter = QPainter(page_pixmap)
x = max(16, page.width() - menu_pixmap.width() - 24)
y = min(page.height() - menu_pixmap.height() - 16, 330)
painter.drawPixmap(x, max(16, y), menu_pixmap)
painter.end()
menu.hide()
if not page_pixmap.save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
return path
def _payment_qr_fixture() -> QPixmap:
"""Create only the renderer's stand-in for a server-fetched QR image."""
image = QImage(256, 256, QImage.Format.Format_ARGB32)
image.fill(QColor("#FFFFFF"))
painter = QPainter(image)
painter.setPen(Qt.PenStyle.NoPen)
module = 8
origin = 12
def draw_finder(left: int, top: int) -> None:
painter.setBrush(QColor("#111111"))
painter.drawRect(origin + left * module, origin + top * module, 7 * module, 7 * module)
painter.setBrush(QColor("#FFFFFF"))
painter.drawRect(
origin + (left + 1) * module,
origin + (top + 1) * module,
5 * module,
5 * module,
)
painter.setBrush(QColor("#111111"))
painter.drawRect(
origin + (left + 2) * module,
origin + (top + 2) * module,
3 * module,
3 * module,
)
for y in range(29):
for x in range(29):
inside_finder = (x < 8 and y < 8) or (x > 20 and y < 8) or (x < 8 and y > 20)
if not inside_finder and ((x * 11 + y * 7 + x * y) % 5 in {0, 2}):
painter.setBrush(QColor("#111111"))
painter.drawRect(origin + x * module, origin + y * module, module, module)
draw_finder(0, 0)
draw_finder(22, 0)
draw_finder(0, 22)
painter.end()
return QPixmap.fromImage(image)
def _save_with_payment_qr(
page: ConsultationsPage,
path: Path,
) -> Path:
record = page.table_host.model.rows[0]
dialog = consultations_module._DiagnosisOrderQrDialog(
record,
"ZYT202608100001",
page,
)
dialog.set_result("https://api.zyt.example/payment/ZYT202608100001/qrcode.png")
dialog.preview.show_loading()
dialog.preview.setText("")
dialog.preview.setPixmap(_payment_qr_fixture())
dialog.ensurePolished()
dialog.adjustSize()
dialog.show()
QApplication.processEvents()
page_pixmap = page.grab()
dialog_pixmap = dialog.grab()
painter = QPainter(page_pixmap)
painter.fillRect(page_pixmap.rect(), QColor(0, 0, 0, 52))
x = (page_pixmap.width() - dialog_pixmap.width()) // 2
y = max(18, (page_pixmap.height() - dialog_pixmap.height()) // 2)
painter.fillRect(
x - 8,
y - 8,
dialog_pixmap.width() + 16,
dialog_pixmap.height() + 16,
QColor(0, 0, 0, 34),
)
painter.drawPixmap(x, y, dialog_pixmap)
painter.end()
dialog.close()
if not page_pixmap.save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
return path
def render() -> list[Path]:
app = QApplication.instance() or QApplication([])
# The offscreen Windows plugin does not enumerate system fonts. Register
# the same CJK face used by the production QSS when it is available.
font_path = Path("C:/Windows/Fonts/msyh.ttc")
if font_path.is_file():
font_id = QFontDatabase.addApplicationFont(str(font_path))
families = QFontDatabase.applicationFontFamilies(font_id)
if families:
app.setFont(QFont(families[0], 9))
root = Path(__file__).resolve().parents[1]
output = root / "artifacts" / "diagnosis_visual"
output.mkdir(parents=True, exist_ok=True)
paths: list[Path] = []
repository = ScreenshotRepository()
for width, height in ((1024, 640), (1440, 900)):
page = _new_page(app, repository, width, height)
path = output / f"diagnosis_{width}x{height}.png"
paths.append(_save(page, path))
page.close()
app.processEvents()
scenarios: tuple[tuple[str, int, int, str], ...] = (
("loading", 1280, 800, "loading"),
("empty", 1280, 800, "empty"),
("error", 1280, 800, "error"),
("hover_warning", 1280, 800, "hover"),
("focus", 1280, 800, "focus"),
("permissions_cropped", 1280, 800, "permissions"),
("horizontal_scroll", 1024, 640, "horizontal"),
("pending_assign", 1280, 800, "pending_assign"),
("advanced_filters", 1280, 800, "advanced"),
)
for filename, width, height, state in scenarios:
permissions = (
PermissionSet(["tcm.diagnosis/readonlyDetail"])
if state == "permissions"
else PermissionSet(["*"])
)
page = _new_page(app, repository, width, height, permissions=permissions)
if state == "loading":
page.table_host.begin_loading()
page.loading_overlay.start()
elif state == "empty":
page.table_host.set_rows([])
page.pager.update_state(1, 0)
elif state == "error":
page.table_host.set_rows([])
page.pager.update_state(1, 0)
page.table_host.show_error("列表加载失败,请重试\n网络连接不可用")
elif state == "hover":
page.table_host.model.set_hover_row(1)
page.table_host.main.viewport().update()
page.table_host.fixed.viewport().update()
elif state == "focus":
page.activateWindow()
page.keyword_edit.setFocus(Qt.FocusReason.TabFocusReason)
elif state == "horizontal":
scrollbar = page.table_host.main.horizontalScrollBar()
scrollbar.setValue(scrollbar.maximum())
elif state == "pending_assign":
page._appointment_date = ""
page._pending_assign = "1"
page._update_quick_buttons()
elif state == "advanced":
page.more_filter_button.setChecked(True)
page._toggle_advanced_filters(True)
app.processEvents()
page.page_scroll.verticalScrollBar().setValue(0)
if state != "focus":
app.processEvents()
path = output / f"diagnosis_{filename}_{width}x{height}.png"
paths.append(_save(page, path))
page.loading_overlay.stop()
page.close()
app.processEvents()
page = _new_page(app, repository, 1280, 800)
full_menu = output / "diagnosis_full_menu_1280x800.png"
paths.append(_save_with_menu(page, full_menu))
page.close()
app.processEvents()
page = _new_page(app, repository, 1280, 800)
payment_qr = output / "diagnosis_order_qrcode_1280x800.png"
paths.append(_save_with_payment_qr(page, payment_qr))
page.close()
app.processEvents()
page = _new_page(app, repository, 1280, 800)
double = _row(880, 0)
double.update(
{
"appointment_id": 2880,
"appointment_status": 1,
"appointments": [
{
"id": 2880,
"status": 1,
"doctor_id": 18,
"doctor_name": "陈医生",
"time_text": "今天 09:00-09:30",
},
{
"id": 2881,
"status": 4,
"doctor_id": 19,
"doctor_name": "李医生",
"time_text": "昨天 15:00-15:30",
},
],
}
)
page.table_host.set_rows([double, _row(881, 1), _row(882, 3)])
page.pager.update_state(1, 3)
for _ in range(4):
page.page_scroll.verticalScrollBar().setValue(0)
app.processEvents()
double_cancel = output / "diagnosis_double_appointment_cancel_1280x800.png"
paths.append(_save(page, double_cancel))
page.close()
app.processEvents()
return paths
if __name__ == "__main__":
for rendered in render():
print(rendered)
@@ -0,0 +1,146 @@
"""Render deterministic prescription-editor drawer references."""
from __future__ import annotations
import os
from pathlib import Path
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtGui import QFont, QFontDatabase
from PySide6.QtWidgets import QApplication, QWidget
from doctor_workstation.ui import apply_theme
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
from doctor_workstation.ui.dialogs.prescription import PrescriptionEditorDialog
class VisualRepository:
def list_medicines(self, **_filters: Any) -> dict[str, Any]:
return {"lists": [], "count": 0}
def _seed() -> dict[str, Any]:
return {
"diagnosis_id": 501,
"appointment_id": 2501,
"patient_name": "林晓岚",
"phone": "13800138000",
"gender": 0,
"age": 34,
"visit_no": "1K00002501",
"prescription_date": "2026-08-11",
"tongue": "舌淡红、苔薄白",
"pulse": "面色少华",
"pulse_condition": "脉细",
"clinical_diagnosis": "气阴两虚,脾气不足",
"prescription_type": "浓缩水丸",
"dosage_amount": 3,
"dosage_unit": "g",
"dosage_bag_count": 2,
"dose_count": 7,
"dose_unit": "",
"usage_days": 7,
"times_per_day": 2,
"usage_instruction": "早晚各一次,温水送服",
"usage_time": "饭后",
"usage_way": "温水送服",
"dietary_taboo": ["生冷食物", "浓茶"],
"usage_notes": "服药期间规律记录空腹及餐后血糖。",
"aux_usage": {
"dosage_amount": 2,
"dosage_bag_count": 1,
"times_per_day": 1,
"usage_days": 7,
"prescription_name": "宁心辅方",
},
"doctor_name": "陈医生",
"herbs": [
{"medicine_id": 11, "name": "黄芪", "dosage": 15, "formula_type": "主方"},
{"medicine_id": 12, "name": "党参", "dosage": 12, "formula_type": "主方"},
{"medicine_id": 13, "name": "白术", "dosage": 10, "formula_type": "主方"},
{"medicine_id": 14, "name": "茯苓", "dosage": 12, "formula_type": "主方"},
{"medicine_id": 15, "name": "麦冬", "dosage": 9, "formula_type": "主方"},
{"medicine_id": 16, "name": "五味子", "dosage": 6, "formula_type": "主方"},
{"medicine_id": 17, "name": "酸枣仁", "dosage": 12, "formula_type": "辅方"},
{"medicine_id": 18, "name": "远志", "dosage": 6, "formula_type": "辅方"},
],
}
def _make_editor(host: QWidget) -> PrescriptionEditorDialog:
editor = PrescriptionEditorDialog(
VisualRepository(),
_seed(),
mode="add",
current_user=SimpleNamespace(id=1, name="陈医生"),
parent=host,
)
# Diagnosis-detail callers apply their scoped sheet after construction. The
# prescription surface owns its own scoped rules, so this also verifies that
# real entry path rather than a renderer-only appearance.
editor.setObjectName("DiagnosisPrescriptionEditor")
editor.setStyleSheet(diagnosis_module.DIAGNOSIS_QSS)
return editor
def _settle(app: QApplication, rounds: int = 8) -> None:
for _ in range(rounds):
app.processEvents()
def render() -> list[Path]:
app = QApplication.instance() or QApplication([])
apply_theme(app)
font_path = Path("C:/Windows/Fonts/msyh.ttc")
if font_path.is_file():
font_id = QFontDatabase.addApplicationFont(str(font_path))
families = QFontDatabase.applicationFontFamilies(font_id)
if families:
app.setFont(QFont(families[0], 9))
output = Path(__file__).resolve().parents[1] / "artifacts" / "diagnosis_visual"
output.mkdir(parents=True, exist_ok=True)
rendered: list[Path] = []
for host_width, height in ((1440, 900), (1024, 768), (920, 780)):
host = QWidget()
host.resize(host_width, height)
host.show()
editor = _make_editor(host)
editor.show()
_settle(app)
editor.body_scroll.verticalScrollBar().setValue(0)
_settle(app, 3)
width = min(PrescriptionEditorDialog.DRAWER_WIDTH, host_width)
path = output / f"diagnosis_state_prescription_editor_{width}x{height}.png"
if not editor.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
rendered.append(path)
editor.close()
host.close()
_settle(app, 2)
host = QWidget()
host.resize(1440, 900)
host.show()
editor = _make_editor(host)
editor.show()
_settle(app)
for index, state in ((1, "herbs"), (2, "usage"), (3, "signature")):
editor.tabs.setCurrentIndex(index)
_settle(app, 4)
path = output / f"diagnosis_state_prescription_editor_{state}_1200x900.png"
if not editor.grab().save(str(path), "PNG"):
raise RuntimeError(f"failed to save {path}")
rendered.append(path)
editor.close()
host.close()
return rendered
if __name__ == "__main__":
for rendered_path in render():
print(rendered_path)
+16
View File
@@ -7,6 +7,7 @@ $ErrorActionPreference = "Stop"
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$FrozenExecutable = Join-Path $ProjectRoot "dist\DoctorWorkstation\DoctorWorkstation.exe"
$ProjectPython = Join-Path $ProjectRoot ".venv\Scripts\python.exe"
$ProjectUvCache = Join-Path $ProjectRoot ".uv-cache"
$SourceRoot = Join-Path $ProjectRoot "src"
function Test-ProjectPython {
@@ -67,11 +68,21 @@ try {
"UV_PROJECT_ENVIRONMENT",
"Process"
)
$PreviousUvCache = [Environment]::GetEnvironmentVariable(
"UV_CACHE_DIR",
"Process"
)
New-Item -ItemType Directory -Path $ProjectUvCache -Force | Out-Null
[Environment]::SetEnvironmentVariable(
"UV_PROJECT_ENVIRONMENT",
(Join-Path $ProjectRoot ".venv"),
"Process"
)
[Environment]::SetEnvironmentVariable(
"UV_CACHE_DIR",
$ProjectUvCache,
"Process"
)
Push-Location $ProjectRoot
try {
& $Uv sync --frozen
@@ -86,6 +97,11 @@ try {
$PreviousProjectEnvironment,
"Process"
)
[Environment]::SetEnvironmentVariable(
"UV_CACHE_DIR",
$PreviousUvCache,
"Process"
)
}
if (-not (Test-ProjectPython -Candidate $ProjectPython)) {
throw "uv completed, but the project Python is still unusable: $ProjectPython"