first commit
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
project_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
python_bin="${PYINSTALLER_PYTHON:-$project_root/.venv-build/bin/python}"
|
||||
companion_root="$project_root/video_companion"
|
||||
|
||||
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||
echo "The macOS bundle must be built on macOS." >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -x "$python_bin" ]]; then
|
||||
echo "Build Python was not found: $python_bin" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_FRONTEND_INSTALL:-0}" != "1" ]]; then
|
||||
npm ci --prefix "$companion_root" --no-audit --no-fund
|
||||
fi
|
||||
npm run build --prefix "$companion_root"
|
||||
|
||||
"$python_bin" -m PyInstaller \
|
||||
--noconfirm \
|
||||
--clean \
|
||||
"$project_root/packaging/doctor_workstation.spec"
|
||||
|
||||
artifact="$project_root/dist/DoctorWorkstation.app"
|
||||
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"
|
||||
|
||||
temp_root="${TMPDIR:-/tmp}"
|
||||
temp_root="${temp_root%/}"
|
||||
smoke_root="$(mktemp -d "$temp_root/doctor-workstation-smoke.XXXXXX")"
|
||||
cleanup_smoke() {
|
||||
case "$smoke_root" in
|
||||
"$temp_root"/doctor-workstation-smoke.*) rm -rf -- "$smoke_root" ;;
|
||||
*) echo "Refusing to remove unsafe smoke-test directory: $smoke_root" >&2 ;;
|
||||
esac
|
||||
}
|
||||
trap cleanup_smoke EXIT
|
||||
mkdir -p \
|
||||
"$smoke_root/Library/Application Support" \
|
||||
"$smoke_root/Library/Caches" \
|
||||
"$smoke_root/xdg/config" \
|
||||
"$smoke_root/xdg/state" \
|
||||
"$smoke_root/xdg/cache" \
|
||||
"$smoke_root/tmp"
|
||||
|
||||
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
|
||||
|
||||
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"
|
||||
@@ -0,0 +1,219 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Python = ".venv-build\Scripts\python.exe",
|
||||
[switch]$SkipFrontendInstall
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
$CompanionRoot = Join-Path $ProjectRoot "video_companion"
|
||||
$Spec = Join-Path $ProjectRoot "packaging\doctor_workstation.spec"
|
||||
|
||||
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"))
|
||||
$SmokeRoot = [System.IO.Path]::GetFullPath($SmokeRoot)
|
||||
if (-not ($SmokeRoot.StartsWith($TempRoot, [System.StringComparison]::OrdinalIgnoreCase)) -or
|
||||
-not ([System.IO.Path]::GetFileName($SmokeRoot)).StartsWith("doctor-workstation-smoke-")) {
|
||||
throw "Refusing to use an unsafe smoke-test directory: $SmokeRoot"
|
||||
}
|
||||
|
||||
$Environment = @{
|
||||
"APPDATA" = (Join-Path $SmokeRoot "AppData\Roaming")
|
||||
"LOCALAPPDATA" = (Join-Path $SmokeRoot "AppData\Local")
|
||||
"XDG_CONFIG_HOME" = (Join-Path $SmokeRoot "xdg\config")
|
||||
"XDG_STATE_HOME" = (Join-Path $SmokeRoot "xdg\state")
|
||||
"XDG_CACHE_HOME" = (Join-Path $SmokeRoot "xdg\cache")
|
||||
"DOCTOR_CONFIG_DIR" = (Join-Path $SmokeRoot "doctor\config")
|
||||
"DOCTOR_LOG_DIR" = (Join-Path $SmokeRoot "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"
|
||||
}
|
||||
$PreviousEnvironment = @{}
|
||||
$StandardOutput = Join-Path $SmokeRoot "stdout.txt"
|
||||
$StandardError = Join-Path $SmokeRoot "stderr.txt"
|
||||
|
||||
New-Item -ItemType Directory -Path $SmokeRoot -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $Environment["APPDATA"] -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $Environment["LOCALAPPDATA"] -Force | Out-Null
|
||||
try {
|
||||
foreach ($Name in $Environment.Keys) {
|
||||
$PreviousEnvironment[$Name] = [Environment]::GetEnvironmentVariable($Name, "Process")
|
||||
[Environment]::SetEnvironmentVariable($Name, $Environment[$Name], "Process")
|
||||
}
|
||||
|
||||
# Windows PowerShell 5 can leave ExitCode unset on the object returned
|
||||
# by Start-Process. System.Diagnostics.Process retains the real code
|
||||
# and lets us drain redirected streams without risking a pipe deadlock.
|
||||
$ProcessInfo = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$ProcessInfo.FileName = $Executable
|
||||
$ProcessInfo.Arguments = $Argument
|
||||
$ProcessInfo.UseShellExecute = $false
|
||||
$ProcessInfo.CreateNoWindow = $true
|
||||
$ProcessInfo.RedirectStandardOutput = $true
|
||||
$ProcessInfo.RedirectStandardError = $true
|
||||
$Process = New-Object System.Diagnostics.Process
|
||||
$Process.StartInfo = $ProcessInfo
|
||||
if (-not $Process.Start()) {
|
||||
throw "Unable to start the frozen application smoke test"
|
||||
}
|
||||
$OutputTask = $Process.StandardOutput.ReadToEndAsync()
|
||||
$ErrorTask = $Process.StandardError.ReadToEndAsync()
|
||||
$TimedOut = -not $Process.WaitForExit(30000)
|
||||
if ($TimedOut -and -not $Process.HasExited) {
|
||||
$Process.Kill()
|
||||
}
|
||||
$Process.WaitForExit()
|
||||
$OutputTask.Wait()
|
||||
$ErrorTask.Wait()
|
||||
Set-Content -LiteralPath $StandardOutput -Value $OutputTask.Result -Encoding UTF8
|
||||
Set-Content -LiteralPath $StandardError -Value $ErrorTask.Result -Encoding UTF8
|
||||
|
||||
$DiagnosticFiles = @($StandardOutput, $StandardError)
|
||||
$DiagnosticFiles += Get-ChildItem -LiteralPath $SmokeRoot -Recurse -Filter "*.log" -File |
|
||||
Select-Object -ExpandProperty FullName
|
||||
$Diagnostics = ($DiagnosticFiles | Where-Object { Test-Path -LiteralPath $_ } |
|
||||
ForEach-Object { Get-Content -LiteralPath $_ -Raw -ErrorAction SilentlyContinue }) -join "`n"
|
||||
if ($TimedOut) {
|
||||
throw "$GateName timed out after 30 seconds`n$Diagnostics"
|
||||
}
|
||||
if ($Process.ExitCode -ne 0) {
|
||||
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 "$GateName logs contain an unhandled exception"
|
||||
}
|
||||
Write-Host "$GateName passed ($Argument, isolated offscreen mode)."
|
||||
}
|
||||
finally {
|
||||
foreach ($Name in $Environment.Keys) {
|
||||
[Environment]::SetEnvironmentVariable($Name, $PreviousEnvironment[$Name], "Process")
|
||||
}
|
||||
if (Test-Path -LiteralPath $SmokeRoot) {
|
||||
Remove-Item -LiteralPath $SmokeRoot -Recurse -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $Python -PathType Leaf)) {
|
||||
throw "Build Python was not found: $Python"
|
||||
}
|
||||
|
||||
$Npm = (Get-Command npm.cmd -ErrorAction Stop).Source
|
||||
Push-Location $ProjectRoot
|
||||
try {
|
||||
if (-not $SkipFrontendInstall) {
|
||||
& $Npm ci --prefix $CompanionRoot --no-audit --no-fund
|
||||
if ($LASTEXITCODE -ne 0) { throw "npm ci failed" }
|
||||
}
|
||||
|
||||
& $Npm run build --prefix $CompanionRoot
|
||||
if ($LASTEXITCODE -ne 0) { throw "video companion build failed" }
|
||||
|
||||
& $Python -m PyInstaller --noconfirm --clean $Spec
|
||||
if ($LASTEXITCODE -ne 0) { throw "PyInstaller build failed" }
|
||||
|
||||
$Artifact = Join-Path $ProjectRoot "dist\DoctorWorkstation"
|
||||
$Helper = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "QtWebEngineProcess.exe" -File | Select-Object -First 1
|
||||
$Resources = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "qtwebengine_resources*.pak" -File | Select-Object -First 1
|
||||
$Companion = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "index.html" -File |
|
||||
Where-Object { $_.FullName -like "*video_companion_dist*" } |
|
||||
Select-Object -First 1
|
||||
if (-not $Helper) { throw "QtWebEngineProcess.exe is missing from the artifact" }
|
||||
if (-not $Resources) { throw "QtWebEngine Chromium resources are missing from the artifact" }
|
||||
if (-not $Companion) { throw "video_companion_dist is missing from the artifact" }
|
||||
|
||||
$PythonBase = (& $Python -c "import sys; print(sys.base_prefix)").Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or -not $PythonBase) {
|
||||
throw "Unable to resolve the build Python runtime directory"
|
||||
}
|
||||
foreach ($RuntimeDllName in @("libssl-3-x64.dll", "libcrypto-3-x64.dll")) {
|
||||
$SourceRuntimeDll = Join-Path $PythonBase "DLLs\$RuntimeDllName"
|
||||
$FrozenRuntimeDll = Join-Path $Artifact "_internal\$RuntimeDllName"
|
||||
if (-not (Test-Path -LiteralPath $SourceRuntimeDll -PathType Leaf)) {
|
||||
throw "Build Python runtime dependency is missing: $SourceRuntimeDll"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $FrozenRuntimeDll -PathType Leaf)) {
|
||||
throw "Frozen Python runtime dependency is missing: $FrozenRuntimeDll"
|
||||
}
|
||||
$SourceHash = (Get-FileHash -LiteralPath $SourceRuntimeDll -Algorithm SHA256).Hash
|
||||
$FrozenHash = (Get-FileHash -LiteralPath $FrozenRuntimeDll -Algorithm SHA256).Hash
|
||||
if ($SourceHash -ne $FrozenHash) {
|
||||
throw "Frozen $RuntimeDllName does not match the build Python runtime"
|
||||
}
|
||||
}
|
||||
|
||||
$Executable = Join-Path $Artifact "DoctorWorkstation.exe"
|
||||
if (-not (Test-Path -LiteralPath $Executable -PathType Leaf)) {
|
||||
throw "Frozen application entry point is missing: $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"
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
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"
|
||||
"$script_dir/build_macos.sh"
|
||||
"$project_root/一键运行.command"
|
||||
"$project_root/一键打包.command"
|
||||
"$project_root/run_macos.command"
|
||||
"$project_root/package_macos.command"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
# 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)"
|
||||
[[ -n "$open_line" && -n "$source_line" && "$open_line" -lt "$source_line" ]] || {
|
||||
echo 'Built .app must be opened before source-environment preparation.' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
grep -Fq 'sync --locked' "$script_dir/run_macos.sh"
|
||||
grep -Fq 'sync --locked --extra build' "$script_dir/package_macos.sh"
|
||||
grep -Fq 'ci --prefix "$project_root/video_companion"' "$script_dir/package_macos.sh"
|
||||
grep -Fq '/bin/bash "$script_dir/build_macos.sh"' "$script_dir/package_macos.sh"
|
||||
grep -Fq 'SHASUMS256.txt' "$script_dir/package_macos.sh"
|
||||
grep -Fq '/usr/bin/ditto -c -k --sequesterRsrc --keepParent' "$script_dir/package_macos.sh"
|
||||
grep -Fq '/usr/bin/shasum -a 256' "$script_dir/package_macos.sh"
|
||||
grep -Fq 'scripts/run_macos.sh' "$project_root/一键运行.command"
|
||||
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[@]:1}"; then
|
||||
echo 'macOS entry scripts must not repurpose HOME.' >&2
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
|
||||
set +e
|
||||
trap_output="$(
|
||||
CI=1 DOCTOR_NONINTERACTIVE=1 /bin/bash -c '
|
||||
source "$1"
|
||||
macos_install_exit_trap 1
|
||||
exit 7
|
||||
' macos-contract "$script_dir/macos_helpers.sh" 2>&1
|
||||
)"
|
||||
trap_status=$?
|
||||
set -e
|
||||
[[ "$trap_status" -eq 7 ]] || {
|
||||
printf 'Non-interactive failure trap changed exit status to %s.\n' "$trap_status" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -Fq '操作失败' <<<"$trap_output"
|
||||
|
||||
echo 'macOS entrypoint contracts passed.'
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Shared helpers for Finder-launched macOS entry points. This file is sourced.
|
||||
|
||||
MACOS_PAUSE_ON_SUCCESS=0
|
||||
MACOS_UV_BIN=""
|
||||
MACOS_TEMP_DIRS=()
|
||||
|
||||
macos_register_temp_dir() {
|
||||
MACOS_TEMP_DIRS[${#MACOS_TEMP_DIRS[@]}]="$1"
|
||||
}
|
||||
|
||||
macos_cleanup_temp_dirs() {
|
||||
local directory
|
||||
for directory in "${MACOS_TEMP_DIRS[@]}"; do
|
||||
[[ -n "$directory" && -d "$directory" ]] || continue
|
||||
case "$(basename "$directory")" in
|
||||
doctor-uv.*|doctor-node.*)
|
||||
rm -rf -- "$directory" || \
|
||||
printf '临时目录清理失败:%s\n' "$directory" >&2
|
||||
;;
|
||||
*) printf '跳过不安全的临时目录清理目标:%s\n' "$directory" >&2 ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
macos_should_pause() {
|
||||
[[ -t 0 && -t 1 && -z "${CI:-}" && "${DOCTOR_NONINTERACTIVE:-0}" != "1" ]]
|
||||
}
|
||||
|
||||
macos_on_exit() {
|
||||
local status="$1"
|
||||
trap - EXIT
|
||||
macos_cleanup_temp_dirs
|
||||
if [[ "$status" -ne 0 ]]; then
|
||||
printf '\n操作失败(退出码 %s)。请查看上方信息。\n' "$status" >&2
|
||||
fi
|
||||
if macos_should_pause && { [[ "$status" -ne 0 ]] || [[ "$MACOS_PAUSE_ON_SUCCESS" == "1" ]]; }; then
|
||||
printf '\n按回车键关闭此窗口…'
|
||||
IFS= read -r _ || true
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
macos_install_exit_trap() {
|
||||
MACOS_PAUSE_ON_SUCCESS="${1:-0}"
|
||||
trap 'macos_on_exit "$?"' EXIT
|
||||
}
|
||||
|
||||
macos_die() {
|
||||
printf '错误:%s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
macos_require_darwin() {
|
||||
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||
macos_die "此入口只能在 macOS 上运行。"
|
||||
fi
|
||||
}
|
||||
|
||||
macos_locate_uv() {
|
||||
local candidate
|
||||
candidate="$(command -v uv 2>/dev/null || true)"
|
||||
if [[ -n "$candidate" && -x "$candidate" ]]; then
|
||||
MACOS_UV_BIN="$candidate"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -n "${HOME:-}" ]]; then
|
||||
for candidate in "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv"; do
|
||||
if [[ -x "$candidate" ]]; then
|
||||
MACOS_UV_BIN="$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
fi
|
||||
for candidate in /opt/homebrew/bin/uv /usr/local/bin/uv; do
|
||||
if [[ -x "$candidate" ]]; then
|
||||
MACOS_UV_BIN="$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
macos_ensure_uv() {
|
||||
local curl_bin installer temp_root
|
||||
if macos_locate_uv; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
curl_bin="$(command -v curl 2>/dev/null || true)"
|
||||
[[ -n "$curl_bin" ]] || macos_die "未找到 uv,也未找到用于安装 uv 的 curl。"
|
||||
temp_root="${TMPDIR:-/tmp}"
|
||||
temp_root="${temp_root%/}"
|
||||
[[ -n "$temp_root" ]] || temp_root="/"
|
||||
installer="$(mktemp -d "$temp_root/doctor-uv.XXXXXX")"
|
||||
macos_register_temp_dir "$installer"
|
||||
|
||||
printf '未检测到 uv,正在通过官方 HTTPS 安装器安装…\n' >&2
|
||||
if ! "$curl_bin" --proto '=https' --tlsv1.2 -fsSL \
|
||||
'https://astral.sh/uv/install.sh' -o "$installer/install.sh"; then
|
||||
macos_die "uv 安装器下载失败,请检查网络后重试。"
|
||||
fi
|
||||
if ! UV_NO_MODIFY_PATH=1 /bin/sh "$installer/install.sh"; then
|
||||
macos_die "uv 安装失败。"
|
||||
fi
|
||||
hash -r
|
||||
macos_locate_uv || macos_die "uv 已安装,但未在标准位置找到可执行文件。"
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
project_root="$(cd "$script_dir/.." && pwd -P)"
|
||||
# shellcheck source=macos_helpers.sh
|
||||
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=""
|
||||
|
||||
use_node_pair() {
|
||||
local node_candidate="$1"
|
||||
local npm_candidate="$2"
|
||||
local major version
|
||||
[[ -x "$node_candidate" && -x "$npm_candidate" ]] || return 1
|
||||
version="$("$node_candidate" --version 2>/dev/null || true)"
|
||||
major="${version#v}"
|
||||
major="${major%%.*}"
|
||||
case "$major" in
|
||||
''|*[!0-9]*) return 1 ;;
|
||||
esac
|
||||
[[ "$major" -ge 20 ]] || return 1
|
||||
NODE_BIN="$node_candidate"
|
||||
NPM_BIN="$npm_candidate"
|
||||
export PATH="$(dirname "$NODE_BIN"):$PATH"
|
||||
}
|
||||
|
||||
locate_node() {
|
||||
local bin_dir node_candidate npm_candidate
|
||||
node_candidate="$(command -v node 2>/dev/null || true)"
|
||||
npm_candidate="$(command -v npm 2>/dev/null || true)"
|
||||
if use_node_pair "$node_candidate" "$npm_candidate"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
for bin_dir in /opt/homebrew/bin /usr/local/bin; do
|
||||
if use_node_pair "$bin_dir/node" "$bin_dir/npm"; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "${HOME:-}" ]]; then
|
||||
for node_candidate in "$HOME"/.nvm/versions/node/*/bin/node; do
|
||||
[[ -x "$node_candidate" ]] || continue
|
||||
npm_candidate="$(dirname "$node_candidate")/npm"
|
||||
if use_node_pair "$node_candidate" "$npm_candidate"; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
install_local_node() {
|
||||
local architecture archive archive_path cache_root curl_bin download_dir
|
||||
local expected extracted install_dir node_version shasums_path actual
|
||||
node_version="${DOCTOR_NODE_VERSION:-22.14.0}"
|
||||
case "$(uname -m)" in
|
||||
arm64) architecture="arm64" ;;
|
||||
x86_64) architecture="x64" ;;
|
||||
*) macos_die "不支持的 Mac CPU 架构:$(uname -m)" ;;
|
||||
esac
|
||||
|
||||
if [[ -n "${XDG_CACHE_HOME:-}" ]]; then
|
||||
cache_root="$XDG_CACHE_HOME/DoctorWorkstation/tools"
|
||||
elif [[ -n "${HOME:-}" ]]; then
|
||||
cache_root="$HOME/Library/Caches/DoctorWorkstation/tools"
|
||||
else
|
||||
macos_die "无法确定 Node 工具缓存目录:HOME 与 XDG_CACHE_HOME 均未设置。"
|
||||
fi
|
||||
install_dir="$cache_root/node-v$node_version-darwin-$architecture"
|
||||
if use_node_pair "$install_dir/bin/node" "$install_dir/bin/npm"; then
|
||||
return 0
|
||||
fi
|
||||
if [[ -e "$install_dir" ]]; then
|
||||
macos_die "Node 缓存不完整,请删除后重试:$install_dir"
|
||||
fi
|
||||
|
||||
curl_bin="$(command -v curl 2>/dev/null || true)"
|
||||
[[ -n "$curl_bin" ]] || macos_die "未找到用于下载 Node.js 的 curl。"
|
||||
mkdir -p "$cache_root"
|
||||
download_dir="$(mktemp -d "$cache_root/doctor-node.XXXXXX")"
|
||||
macos_register_temp_dir "$download_dir"
|
||||
archive="node-v$node_version-darwin-$architecture.tar.gz"
|
||||
archive_path="$download_dir/$archive"
|
||||
shasums_path="$download_dir/SHASUMS256.txt"
|
||||
|
||||
printf '未检测到 Node.js 20+ 与 npm,正在下载 Node.js %s(%s)…\n' \
|
||||
"$node_version" "$architecture"
|
||||
if ! "$curl_bin" --proto '=https' --tlsv1.2 -fsSL \
|
||||
"https://nodejs.org/dist/v$node_version/$archive" -o "$archive_path"; then
|
||||
macos_die "Node.js 下载失败,请检查网络后重试。"
|
||||
fi
|
||||
if ! "$curl_bin" --proto '=https' --tlsv1.2 -fsSL \
|
||||
"https://nodejs.org/dist/v$node_version/SHASUMS256.txt" -o "$shasums_path"; then
|
||||
macos_die "Node.js 校验文件下载失败。"
|
||||
fi
|
||||
expected="$(/usr/bin/awk -v file="$archive" '$2 == file { print $1; exit }' "$shasums_path")"
|
||||
actual="$(/usr/bin/shasum -a 256 "$archive_path" | /usr/bin/awk '{print $1}')"
|
||||
[[ -n "$expected" && "$actual" == "$expected" ]] || macos_die "Node.js 下载包 SHA-256 校验失败。"
|
||||
|
||||
/usr/bin/tar -xzf "$archive_path" -C "$download_dir"
|
||||
extracted="$download_dir/node-v$node_version-darwin-$architecture"
|
||||
[[ -d "$extracted" ]] || macos_die "Node.js 下载包结构无效。"
|
||||
if [[ ! -e "$install_dir" ]]; then
|
||||
/bin/mv "$extracted" "$install_dir"
|
||||
fi
|
||||
use_node_pair "$install_dir/bin/node" "$install_dir/bin/npm" || \
|
||||
macos_die "Node.js 安装完成,但 node/npm 无法执行。"
|
||||
}
|
||||
|
||||
macos_ensure_uv
|
||||
cd "$project_root"
|
||||
printf '正在同步 Python 与 PyInstaller 构建依赖…\n'
|
||||
"$MACOS_UV_BIN" sync --locked --extra build
|
||||
python_bin="$project_root/.venv/bin/python"
|
||||
[[ -x "$python_bin" ]] || macos_die "uv 未生成可用的构建 Python:$python_bin"
|
||||
|
||||
locate_node || install_local_node
|
||||
printf 'Node.js: %s;npm: %s\n' \
|
||||
"$("$NODE_BIN" --version)" "$("$NPM_BIN" --version)"
|
||||
printf '正在安装锁定的 companion 依赖…\n'
|
||||
"$NPM_BIN" ci --prefix "$project_root/video_companion" --no-audit --no-fund
|
||||
|
||||
printf '正在构建 DoctorWorkstation.app…\n'
|
||||
PYINSTALLER_PYTHON="$python_bin" SKIP_FRONTEND_INSTALL=1 \
|
||||
/bin/bash "$script_dir/build_macos.sh"
|
||||
|
||||
artifact="$project_root/dist/DoctorWorkstation.app"
|
||||
project_version="$(/usr/bin/awk -F '"' '/^version = "/ { print $2; exit }' \
|
||||
"$project_root/pyproject.toml")"
|
||||
[[ -n "$project_version" ]] || macos_die "无法从 pyproject.toml 读取版本号。"
|
||||
case "$(uname -m)" in
|
||||
arm64) release_arch="arm64" ;;
|
||||
x86_64) release_arch="x64" ;;
|
||||
*) macos_die "不支持的 Mac CPU 架构:$(uname -m)" ;;
|
||||
esac
|
||||
release_zip="$project_root/dist/DoctorWorkstation-macOS-$release_arch-$project_version.zip"
|
||||
checksum_file="$release_zip.sha256"
|
||||
rm -f -- "$release_zip" "$checksum_file"
|
||||
printf '正在生成可分发 ZIP…\n'
|
||||
/usr/bin/ditto -c -k --sequesterRsrc --keepParent "$artifact" "$release_zip"
|
||||
release_hash="$(/usr/bin/shasum -a 256 "$release_zip" | /usr/bin/awk '{print $1}')"
|
||||
printf '%s %s\n' "$release_hash" "$(basename "$release_zip")" >"$checksum_file"
|
||||
|
||||
printf '\n打包成功:%s\n' "$artifact"
|
||||
printf '分发包:%s\n' "$release_zip"
|
||||
printf 'SHA-256:%s\n' "$release_hash"
|
||||
@@ -0,0 +1,216 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$ValidateOnly
|
||||
)
|
||||
|
||||
$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"
|
||||
$PackageLock = Join-Path $CompanionRoot "package-lock.json"
|
||||
$Artifact = Join-Path $ProjectRoot "dist\DoctorWorkstation"
|
||||
$Executable = Join-Path $Artifact "DoctorWorkstation.exe"
|
||||
$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)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Candidate -PathType Leaf)) {
|
||||
return $false
|
||||
}
|
||||
try {
|
||||
& $Candidate -c "import sys, httpx, PyInstaller, PySide6; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" 2>$null
|
||||
return $LASTEXITCODE -eq 0
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Find-Application {
|
||||
param([Parameter(Mandatory = $true)][string]$Name)
|
||||
|
||||
$Command = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if ($Command) { return $Command.Source }
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($RequiredFile in @(
|
||||
$BuildScript,
|
||||
$PackageLock,
|
||||
(Join-Path $ProjectRoot "uv.lock"),
|
||||
$ProjectMetadata,
|
||||
$MediaSmokeHook,
|
||||
$ReleaseLauncherTemplate
|
||||
)) {
|
||||
if (-not (Test-Path -LiteralPath $RequiredFile -PathType Leaf)) {
|
||||
throw "Required build file is missing: $RequiredFile"
|
||||
}
|
||||
}
|
||||
|
||||
$Npm = Find-Application -Name "npm.cmd"
|
||||
$Node = Find-Application -Name "node.exe"
|
||||
if (-not $Npm -or -not $Node) {
|
||||
throw "Node.js and npm are required to build the video companion."
|
||||
}
|
||||
$NodeVersionText = (& $Node --version).Trim().TrimStart("v")
|
||||
$NodeVersion = [version]$NodeVersionText
|
||||
if ($NodeVersion.Major -lt 20) {
|
||||
throw "Node.js 20 or newer is required; found $NodeVersionText"
|
||||
}
|
||||
|
||||
$Uv = Find-Application -Name "uv"
|
||||
if ($ValidateOnly) {
|
||||
if (-not $Uv -and
|
||||
-not (Test-BuildPython -Candidate $BuildPython) -and
|
||||
-not (Test-BuildPython -Candidate $FallbackPython)) {
|
||||
throw "Neither uv nor a usable Python environment with build dependencies was found."
|
||||
}
|
||||
Write-Host "Windows package entry validation passed."
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($Uv) {
|
||||
Write-Host "Preparing locked Python build dependencies in $BuildEnvironment ..."
|
||||
$PreviousProjectEnvironment = [Environment]::GetEnvironmentVariable(
|
||||
"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
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "uv sync for build dependencies failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
[Environment]::SetEnvironmentVariable(
|
||||
"UV_PROJECT_ENVIRONMENT",
|
||||
$PreviousProjectEnvironment,
|
||||
"Process"
|
||||
)
|
||||
[Environment]::SetEnvironmentVariable(
|
||||
"UV_CACHE_DIR",
|
||||
$PreviousUvCache,
|
||||
"Process"
|
||||
)
|
||||
}
|
||||
}
|
||||
elseif (Test-BuildPython -Candidate $BuildPython) {
|
||||
Write-Host "Using existing build environment: $BuildPython"
|
||||
}
|
||||
elseif (Test-BuildPython -Candidate $FallbackPython) {
|
||||
$BuildPython = $FallbackPython
|
||||
Write-Host "Using existing project environment with build dependencies: $BuildPython"
|
||||
}
|
||||
else {
|
||||
throw "Install uv or prepare .venv-build with the project's build dependencies."
|
||||
}
|
||||
|
||||
if (-not (Test-BuildPython -Candidate $BuildPython)) {
|
||||
throw "The prepared build Python is unusable: $BuildPython"
|
||||
}
|
||||
|
||||
Write-Host "Installing locked video companion dependencies..."
|
||||
& $Npm ci --prefix $CompanionRoot --no-audit --no-fund
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "npm ci failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
|
||||
Write-Host "Running the existing Windows release build..."
|
||||
& $BuildScript -Python $BuildPython -SkipFrontendInstall
|
||||
if (-not (Test-Path -LiteralPath $Executable -PathType Leaf)) {
|
||||
throw "Build completed without the expected executable: $Executable"
|
||||
}
|
||||
|
||||
$ProjectText = [System.IO.File]::ReadAllText($ProjectMetadata)
|
||||
$VersionMatch = [regex]::Match(
|
||||
$ProjectText,
|
||||
'(?m)^\s*version\s*=\s*"([^"]+)"'
|
||||
)
|
||||
if (-not $VersionMatch.Success) {
|
||||
throw "Unable to read the project version from $ProjectMetadata"
|
||||
}
|
||||
$ProjectVersion = $VersionMatch.Groups[1].Value
|
||||
$ReleaseZip = Join-Path $DistributionRoot (
|
||||
"DoctorWorkstation-Windows-x64-$ProjectVersion.zip"
|
||||
)
|
||||
$ChecksumFile = Join-Path $DistributionRoot "SHA256SUMS.txt"
|
||||
Copy-Item -LiteralPath $ReleaseLauncherTemplate -Destination $ReleaseLauncher -Force
|
||||
if (Test-Path -LiteralPath $ReleaseZip) {
|
||||
Remove-Item -LiteralPath $ReleaseZip -Force
|
||||
}
|
||||
|
||||
Write-Host "Creating the distributable ZIP..."
|
||||
$SevenZip = Find-Application -Name "7z.exe"
|
||||
if ($SevenZip) {
|
||||
Push-Location $DistributionRoot
|
||||
try {
|
||||
& $SevenZip a -tzip -mx=5 -mmt=on $ReleaseZip `
|
||||
".\DoctorWorkstation" ".\Start_DoctorWorkstation.bat"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "7-Zip failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
else {
|
||||
Compress-Archive `
|
||||
-LiteralPath @($Artifact, $ReleaseLauncher) `
|
||||
-DestinationPath $ReleaseZip `
|
||||
-CompressionLevel Optimal
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $ReleaseZip -PathType Leaf)) {
|
||||
throw "Packaging completed without the expected ZIP: $ReleaseZip"
|
||||
}
|
||||
|
||||
$ReleaseHash = (Get-FileHash -LiteralPath $ReleaseZip -Algorithm SHA256).Hash
|
||||
$ChecksumLine = "$ReleaseHash $([System.IO.Path]::GetFileName($ReleaseZip))`r`n"
|
||||
$Utf8WithoutBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($ChecksumFile, $ChecksumLine, $Utf8WithoutBom)
|
||||
|
||||
Write-Host "Windows package complete." -ForegroundColor Green
|
||||
Write-Host "Artifact: $Artifact" -ForegroundColor Green
|
||||
Write-Host "Release ZIP: $ReleaseZip" -ForegroundColor Green
|
||||
Write-Host "SHA-256: $ReleaseHash" -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
$FailureExitCode = if ($LASTEXITCODE -is [int] -and $LASTEXITCODE -ne 0) {
|
||||
$LASTEXITCODE
|
||||
}
|
||||
else {
|
||||
1
|
||||
}
|
||||
Write-Host "Windows packaging failed." -ForegroundColor Red
|
||||
Write-Host $_.Exception.Message -ForegroundColor Red
|
||||
exit $FailureExitCode
|
||||
}
|
||||
@@ -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,82 @@
|
||||
"""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] = []
|
||||
|
||||
# Keep the two regression sizes and add a full-HD deliverable for design review.
|
||||
for width, height in ((1024, 640), (1440, 900), (1920, 1080)):
|
||||
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,204 @@
|
||||
"""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.core.permissions import PermissionSet
|
||||
from doctor_workstation.ui import apply_theme
|
||||
from doctor_workstation.ui.dialogs.prescription import (
|
||||
PrescriptionDetailDialog,
|
||||
PrescriptionEditorDialog,
|
||||
)
|
||||
from doctor_workstation.ui.dialogs.prescription_ai import PrescriptionAiReportDialog
|
||||
|
||||
|
||||
class VisualRepository:
|
||||
def list_medicines(self, **_filters: Any) -> dict[str, Any]:
|
||||
return {"lists": [], "count": 0}
|
||||
|
||||
def list_prescription_template_ai_reports(self, template_id: int) -> dict[str, Any]:
|
||||
report = {
|
||||
"summary": "益气健脾为主,兼顾养阴安神,适合脾气不足、气阴两虚方向的复核参考。",
|
||||
"possible_symptoms": ["乏力气短", "食少便溏", "睡眠不安"],
|
||||
"main_indications": "气阴两虚、脾气不足所见的倦怠与纳差。",
|
||||
"efficacy": ["益气健脾", "养阴生津", "宁心安神"],
|
||||
"suitable_people": ["辨证属气阴两虚者", "需由医师结合四诊确认"],
|
||||
"compatibility_analysis": "黄芪、党参、白术与茯苓协同补气健脾,麦冬、五味子兼顾养阴敛津,酸枣仁与远志用于宁心安神。",
|
||||
"cautions": ["仍需核对过敏史与现用药", "症状变化时及时复诊"],
|
||||
"disclaimer": "仅供专业人员辅助审方,不替代辨证、诊断和处方审核。",
|
||||
}
|
||||
return {
|
||||
"prescription_id": template_id,
|
||||
"can_refresh": True,
|
||||
"can_edit": True,
|
||||
"reports": [
|
||||
{
|
||||
"report_id": 21,
|
||||
"model_key": "qwen",
|
||||
"model_label": "千问",
|
||||
"model_name": "qwen3.6-35b",
|
||||
"generated_at": "2026-08-13 10:32:00",
|
||||
"report": report,
|
||||
"is_stale": False,
|
||||
"is_edited": True,
|
||||
},
|
||||
{
|
||||
"report_id": 22,
|
||||
"model_key": "openai",
|
||||
"model_label": "OpenAI",
|
||||
"model_name": "gpt-5.6-sol",
|
||||
"generated_at": "2026-08-13 10:32:00",
|
||||
"report": report,
|
||||
"is_stale": False,
|
||||
"is_edited": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _seed() -> dict[str, Any]:
|
||||
return {
|
||||
"diagnosis_id": 501,
|
||||
"id": 2501,
|
||||
"appointment_id": 2501,
|
||||
"prescription_no": "RX202608130021",
|
||||
"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": "陈医生",
|
||||
"audit_status": 1,
|
||||
"create_time": "2026-08-13 10:26:00",
|
||||
"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,
|
||||
)
|
||||
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" / "subwindow_exact"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
rendered: list[Path] = []
|
||||
|
||||
host = QWidget()
|
||||
host.resize(1440, 900)
|
||||
host.show()
|
||||
editor = _make_editor(host)
|
||||
editor.show()
|
||||
_settle(app)
|
||||
editor.body_scroll.verticalScrollBar().setValue(0)
|
||||
_settle(app, 4)
|
||||
path = output / "prescription_editor_860x900.png"
|
||||
if not editor.grab().save(str(path), "PNG"):
|
||||
raise RuntimeError(f"failed to save {path}")
|
||||
rendered.append(path)
|
||||
editor.close()
|
||||
host.close()
|
||||
|
||||
detail = PrescriptionDetailDialog(
|
||||
_seed(),
|
||||
can_open_diagnosis=True,
|
||||
can_open_orders=True,
|
||||
)
|
||||
detail.show()
|
||||
_settle(app, 10)
|
||||
path = output / "prescription_detail_920x780.png"
|
||||
if not detail.grab().save(str(path), "PNG"):
|
||||
raise RuntimeError(f"failed to save {path}")
|
||||
rendered.append(path)
|
||||
detail.close()
|
||||
_settle(app, 2)
|
||||
|
||||
ai_dialog = PrescriptionAiReportDialog(
|
||||
VisualRepository(),
|
||||
PermissionSet(["*", "tcm.prescriptionLibrary/editAiReport"]),
|
||||
)
|
||||
ai_dialog.open_for(
|
||||
{
|
||||
"id": 2501,
|
||||
"prescription_name": "益气养阴安神方",
|
||||
"formula_type": "主方",
|
||||
"herbs": _seed()["herbs"],
|
||||
}
|
||||
)
|
||||
ai_dialog.show()
|
||||
_settle(app, 16)
|
||||
path = output / "prescription_ai_report_920x760.png"
|
||||
if not ai_dialog.grab().save(str(path), "PNG"):
|
||||
raise RuntimeError(f"failed to save {path}")
|
||||
rendered.append(path)
|
||||
ai_dialog.close()
|
||||
_settle(app, 2)
|
||||
return rendered
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for rendered_path in render():
|
||||
print(rendered_path)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Render the 1710×920 reception AI acceptance artifact offscreen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import QPoint, QThreadPool
|
||||
from PySide6.QtGui import QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
from doctor_workstation.ui.shell import ShellWindow
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
font_path = Path(r"C:\Windows\Fonts\msyh.ttc")
|
||||
if font_path.is_file():
|
||||
QFontDatabase.addApplicationFont(str(font_path))
|
||||
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login("doctor", "doctor123")
|
||||
window = ShellWindow(repository, session)
|
||||
window.resize(1710, 920)
|
||||
window.show()
|
||||
if not window.navigate("reception"):
|
||||
raise RuntimeError("reception navigation is unavailable")
|
||||
|
||||
for _index in range(4):
|
||||
app.processEvents()
|
||||
QThreadPool.globalInstance().waitForDone(10_000)
|
||||
app.processEvents()
|
||||
|
||||
output = Path(__file__).resolve().parents[1] / "artifacts" / "reception_ai_exact" / "reception.png"
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
pixmap = window.grab()
|
||||
if pixmap.size().width() != 1710 or pixmap.size().height() != 920:
|
||||
raise RuntimeError(f"unexpected render size: {pixmap.size().width()}x{pixmap.size().height()}")
|
||||
if not pixmap.save(str(output), "PNG"):
|
||||
raise RuntimeError(f"failed to save {output}")
|
||||
|
||||
page = window.pages.get("reception")
|
||||
if page is not None:
|
||||
left = page.ai_analysis_card.geometry()
|
||||
right = page.ai_assistant_card.geometry()
|
||||
left_global = page.ai_analysis_card.mapTo(window, QPoint(0, 0))
|
||||
right_global = page.ai_assistant_card.mapTo(window, QPoint(0, 0))
|
||||
print(
|
||||
"AI_CARDS",
|
||||
left.x(),
|
||||
left.y(),
|
||||
left.width(),
|
||||
left.height(),
|
||||
right.x(),
|
||||
right.y(),
|
||||
right.width(),
|
||||
right.height(),
|
||||
page._ai_analysis_state,
|
||||
)
|
||||
print(
|
||||
"AI_CARDS_GLOBAL",
|
||||
left_global.x(),
|
||||
left_global.y(),
|
||||
right_global.x(),
|
||||
right_global.y(),
|
||||
)
|
||||
print(output)
|
||||
window.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Render the structured reception medication/case card for visual review."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import QThreadPool
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
from doctor_workstation.ui.shell import ShellWindow
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login("doctor", "doctor123")
|
||||
window = ShellWindow(repository, session)
|
||||
window.resize(1710, 920)
|
||||
window.show()
|
||||
if not window.navigate("reception"):
|
||||
raise RuntimeError("reception navigation is unavailable")
|
||||
for _index in range(4):
|
||||
app.processEvents()
|
||||
QThreadPool.globalInstance().waitForDone(10_000)
|
||||
|
||||
page = window.pages.get("reception")
|
||||
if page is None:
|
||||
raise RuntimeError("reception page was not created")
|
||||
page.detail_stack.setCurrentIndex(1)
|
||||
medication_index = next(
|
||||
index
|
||||
for index in range(page.detail_tabs.count())
|
||||
if page.detail_tabs.tabText(index) == "用药记录"
|
||||
)
|
||||
page.detail_tabs.setCurrentIndex(medication_index)
|
||||
page._render_case(
|
||||
{"has_prescription": True},
|
||||
{"age": 56, "gender": 1},
|
||||
{
|
||||
"diagnosis_date": "2026-08-14",
|
||||
"diagnosis_type_text": "复诊",
|
||||
"systolic": 148,
|
||||
"diastolic": 92,
|
||||
"blood_pressure_status": "偏高",
|
||||
"fasting_blood_sugar": "6.8",
|
||||
"fasting_blood_sugar_status": "偏高",
|
||||
"clinical_diagnosis": "2 型糖尿病(血糖控制未达标),合并高血压与轻度脂肪肝",
|
||||
"current_medications": [
|
||||
"二甲双胍缓释片 0.5 g,早晚餐后各一次",
|
||||
"格列吡嗪片 5 mg,早餐前一次",
|
||||
],
|
||||
"allergy_history_text": "青霉素过敏",
|
||||
"chief_complaint": "口干口苦、多饮多汗,近期睡眠欠佳",
|
||||
"present_illness": (
|
||||
"近两周空腹血糖波动,伴头昏、腰膝酸软及夜间多尿;"
|
||||
"未发生明确低血糖,服药依从性一般。"
|
||||
),
|
||||
"tongue": "舌红,苔黄腻",
|
||||
"pulse": "脉弦滑",
|
||||
"treatment_principle": "益气养阴、清热利湿,兼顾健脾化痰",
|
||||
"diabetes_history_text": "10 年",
|
||||
"past_history_text": "高血压 6 年、高脂血症 3 年",
|
||||
"family_history_text": "父亲患 2 型糖尿病",
|
||||
"sleep_condition_text": "多梦、入睡困难",
|
||||
"diet_condition_text": "主食量偏多,晚餐较晚",
|
||||
"remark": "重点核对降糖药剂量,复查肝肾功能,并关注夜间低血糖风险。",
|
||||
},
|
||||
)
|
||||
for _index in range(6):
|
||||
app.processEvents()
|
||||
|
||||
output = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "artifacts"
|
||||
/ "reception_medication_case"
|
||||
/ "reception_medication_case_1710x920.png"
|
||||
)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not window.grab().save(str(output), "PNG"):
|
||||
raise RuntimeError(f"failed to save {output}")
|
||||
print(output)
|
||||
window.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Render focused blue-white diagnosis subwindow acceptance references."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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.QtGui import QFont, QFontDatabase # noqa: E402
|
||||
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||
from render_diagnosis_detail_visual import ( # noqa: E402
|
||||
ScreenshotRepository,
|
||||
_run_immediately,
|
||||
)
|
||||
from render_diagnosis_order_video_visual import order_detail # noqa: E402
|
||||
|
||||
from doctor_workstation.core import PermissionSet # noqa: E402
|
||||
from doctor_workstation.services import DemoDoctorRepository # noqa: E402
|
||||
from doctor_workstation.ui.diagnosis_editors import DailyRecordEditorDialog # noqa: E402
|
||||
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module # noqa: E402
|
||||
from doctor_workstation.ui.dialogs import prescription_ai as ai_module # noqa: E402
|
||||
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog # noqa: E402
|
||||
from doctor_workstation.ui.dialogs.prescription_ai import ( # noqa: E402
|
||||
DIAGNOSIS_AI_KIND,
|
||||
PrescriptionAiReportDialog,
|
||||
)
|
||||
|
||||
|
||||
def _save(widget: object, path: Path, app: QApplication) -> None:
|
||||
widget.show()
|
||||
for _ in range(8):
|
||||
app.processEvents()
|
||||
if not widget.grab().save(str(path), "PNG"):
|
||||
raise RuntimeError(f"failed to save {path}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
output = ROOT / "artifacts" / "subwindow_exact"
|
||||
output.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))
|
||||
diagnosis_module.run_async = _run_immediately
|
||||
ai_module.run_async = _run_immediately
|
||||
|
||||
host = DiagnosisDialog(ScreenshotRepository(locked=False), permissions=PermissionSet(["*"]))
|
||||
host.resize(1280, 800)
|
||||
host.open_for(501, editable=True)
|
||||
_save(host, output / "diagnosis_edit_drawer_1280x800.png", app)
|
||||
|
||||
order = host._build_order_detail_dialog(order_detail(), 801)
|
||||
_save(order, output / "diagnosis_order_detail_1280x800.png", app)
|
||||
order.close()
|
||||
host.close()
|
||||
app.processEvents()
|
||||
|
||||
daily = 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.setStyleSheet(diagnosis_module.DIAGNOSIS_QSS)
|
||||
daily.resize(680, 660)
|
||||
_save(daily, output / "diagnosis_daily_editor_680x660.png", app)
|
||||
daily.close()
|
||||
app.processEvents()
|
||||
|
||||
ai_report = PrescriptionAiReportDialog(
|
||||
DemoDoctorRepository(),
|
||||
permissions=PermissionSet(["*"]),
|
||||
kind=DIAGNOSIS_AI_KIND,
|
||||
)
|
||||
ai_report.open_for(
|
||||
{
|
||||
"id": 501,
|
||||
"diagnosis_id": 501,
|
||||
"patient_name": "林晓岚",
|
||||
"diagnosis_date": "2026-08-10",
|
||||
"diagnosis_type": "复诊",
|
||||
"syndrome_type": "气阴两虚",
|
||||
"chief_complaint": "口干乏力,餐后血糖波动",
|
||||
"present_illness": "近两周睡眠改善,仍需记录空腹与餐后血糖。",
|
||||
"clinical_diagnosis": "2 型糖尿病",
|
||||
"treatment_principle": "益气养阴,兼顾饮食与运动管理",
|
||||
"doctor_advice": "连续记录七日血糖并按时复诊",
|
||||
}
|
||||
)
|
||||
ai_report.resize(920, 760)
|
||||
_save(ai_report, output / "diagnosis_ai_report_920x760.png", app)
|
||||
ai_report.close()
|
||||
app.processEvents()
|
||||
|
||||
for path in sorted(output.glob("*.png")):
|
||||
print(path)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
project_root="$(cd "$script_dir/.." && pwd -P)"
|
||||
# shellcheck source=macos_helpers.sh
|
||||
source "$script_dir/macos_helpers.sh"
|
||||
macos_install_exit_trap 0
|
||||
macos_require_darwin
|
||||
|
||||
artifact="$project_root/dist/DoctorWorkstation.app"
|
||||
artifact_executable="$artifact/Contents/MacOS/DoctorWorkstation"
|
||||
if [[ -d "$artifact" && -x "$artifact_executable" ]]; then
|
||||
printf '正在打开已构建应用:%s\n' "$artifact"
|
||||
if /usr/bin/open "$artifact"; then
|
||||
printf '应用已启动。\n'
|
||||
exit 0
|
||||
fi
|
||||
macos_die "DoctorWorkstation.app 存在,但 Finder 无法打开它。"
|
||||
fi
|
||||
|
||||
if [[ -e "$artifact" ]]; then
|
||||
printf '检测到不完整的应用产物,将改为启动源码:%s\n' "$artifact" >&2
|
||||
else
|
||||
printf '尚无已构建应用,正在准备源码运行环境。\n'
|
||||
fi
|
||||
|
||||
macos_ensure_uv
|
||||
cd "$project_root"
|
||||
printf '正在同步 Python 运行依赖(首次运行可能需要几分钟)…\n'
|
||||
"$MACOS_UV_BIN" sync --locked
|
||||
printf '正在启动医生工作站…\n'
|
||||
"$MACOS_UV_BIN" run --frozen doctor-workstation
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$ValidateOnly
|
||||
)
|
||||
|
||||
$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 {
|
||||
param([Parameter(Mandatory = $true)][string]$Candidate)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Candidate -PathType Leaf)) {
|
||||
return $false
|
||||
}
|
||||
try {
|
||||
& $Candidate -c "import sys, httpx, PySide6; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" 2>$null
|
||||
return $LASTEXITCODE -eq 0
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Find-Uv {
|
||||
$Command = Get-Command uv -CommandType Application -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if ($Command) { return $Command.Source }
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
if (Test-Path -LiteralPath $FrozenExecutable -PathType Leaf) {
|
||||
Write-Host "Using packaged application: $FrozenExecutable"
|
||||
if ($ValidateOnly) {
|
||||
Write-Host "Windows run entry validation passed."
|
||||
exit 0
|
||||
}
|
||||
& $FrozenExecutable
|
||||
$ApplicationExitCode = $LASTEXITCODE
|
||||
if ($null -eq $ApplicationExitCode) { $ApplicationExitCode = 0 }
|
||||
if ($ApplicationExitCode -ne 0) {
|
||||
Write-Host "DoctorWorkstation exited with code $ApplicationExitCode" -ForegroundColor Red
|
||||
}
|
||||
exit $ApplicationExitCode
|
||||
}
|
||||
|
||||
$Python = $null
|
||||
if (Test-ProjectPython -Candidate $ProjectPython) {
|
||||
$Python = $ProjectPython
|
||||
Write-Host "Using existing project environment: $ProjectPython"
|
||||
}
|
||||
else {
|
||||
$Uv = Find-Uv
|
||||
if (-not $Uv) {
|
||||
throw "No packaged application or usable .venv was found, and uv is not installed."
|
||||
}
|
||||
if ($ValidateOnly) {
|
||||
Write-Host "Source fallback is available through uv: $Uv"
|
||||
Write-Host "Windows run entry validation passed."
|
||||
exit 0
|
||||
}
|
||||
Write-Host "Preparing the project environment with uv..."
|
||||
$PreviousProjectEnvironment = [Environment]::GetEnvironmentVariable(
|
||||
"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
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "uv sync failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
[Environment]::SetEnvironmentVariable(
|
||||
"UV_PROJECT_ENVIRONMENT",
|
||||
$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"
|
||||
}
|
||||
$Python = $ProjectPython
|
||||
}
|
||||
|
||||
if ($ValidateOnly) {
|
||||
Write-Host "Windows run entry validation passed."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$PreviousPythonPath = [Environment]::GetEnvironmentVariable("PYTHONPATH", "Process")
|
||||
$env:PYTHONPATH = if ($PreviousPythonPath) {
|
||||
$SourceRoot + [System.IO.Path]::PathSeparator + $PreviousPythonPath
|
||||
}
|
||||
else {
|
||||
$SourceRoot
|
||||
}
|
||||
Push-Location $ProjectRoot
|
||||
try {
|
||||
& $Python -m doctor_workstation
|
||||
$ApplicationExitCode = $LASTEXITCODE
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
[Environment]::SetEnvironmentVariable("PYTHONPATH", $PreviousPythonPath, "Process")
|
||||
}
|
||||
if ($null -eq $ApplicationExitCode) { $ApplicationExitCode = 0 }
|
||||
if ($ApplicationExitCode -ne 0) {
|
||||
Write-Host "DoctorWorkstation exited with code $ApplicationExitCode" -ForegroundColor Red
|
||||
}
|
||||
exit $ApplicationExitCode
|
||||
}
|
||||
catch {
|
||||
$FailureExitCode = if ($LASTEXITCODE -is [int] -and $LASTEXITCODE -ne 0) {
|
||||
$LASTEXITCODE
|
||||
}
|
||||
else {
|
||||
1
|
||||
}
|
||||
Write-Host "Unable to start DoctorWorkstation." -ForegroundColor Red
|
||||
Write-Host $_.Exception.Message -ForegroundColor Red
|
||||
exit $FailureExitCode
|
||||
}
|
||||
Reference in New Issue
Block a user