更新
This commit is contained in:
+99
-53
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user