This commit is contained in:
Your Name
2026-08-22 10:46:13 +08:00
parent c06d293424
commit 43e5411b6a
39 changed files with 1607 additions and 300 deletions
+5 -5
View File
@@ -26,7 +26,7 @@ from doctor_workstation.config import AppConfig
from doctor_workstation.core import Session
from doctor_workstation.core.errors import AuthenticationExpiredError
from doctor_workstation.logging_setup import configure_logging
from doctor_workstation.resources import resource_path, video_dist_path
from doctor_workstation.resources import app_icon_path, video_dist_path
from doctor_workstation.services import (
DemoDoctorRepository,
RemoteDoctorRepository,
@@ -772,9 +772,9 @@ class ApplicationController(QObject):
LOGGER.warning("video lifecycle cleanup exceeded its bounded deadline")
return complete
@staticmethod
def _apply_window_icon(window: QWidget) -> None:
icon_file = resource_path("icon.svg")
@staticmethod
def _apply_window_icon(window: QWidget) -> None:
icon_file = app_icon_path()
if icon_file.exists():
window.setWindowIcon(QIcon(str(icon_file)))
@@ -858,7 +858,7 @@ def _create_application(argv: list[str]) -> QApplication:
application.setOrganizationName("ZhenYangTang")
application.setOrganizationDomain("zhenyangtang.com")
application.setQuitOnLastWindowClosed(True)
icon_file = resource_path("icon.svg")
icon_file = app_icon_path()
if icon_file.exists():
application.setWindowIcon(QIcon(str(icon_file)))
apply_theme(application)
+8
View File
@@ -17,6 +17,14 @@ def resource_path(*parts: str) -> Path:
return project_root().joinpath("resources", *parts)
def app_icon_path() -> Path:
return resource_path("branding", "app-icon.png")
def brand_lockup_path() -> Path:
return resource_path("branding", "brand-lockup.png")
def video_dist_path() -> Path:
candidates = (
project_root() / "video_companion" / "dist" / "index.html",
+305 -34
View File
@@ -30,6 +30,9 @@ CHECK_ENDPOINT = "setting.desktop_workstation/check"
MAX_PACKAGE_BYTES = 2 * 1024 * 1024 * 1024
WINDOWS_EXE_NAME = "DoctorWorkstation.exe"
MACOS_APP_NAME = "DoctorWorkstation.app"
PACKAGE_TYPE_ARCHIVE = "archive"
PACKAGE_TYPE_INNO_SETUP = "inno_setup"
SUPPORTED_PACKAGE_TYPES = {PACKAGE_TYPE_ARCHIVE, PACKAGE_TYPE_INNO_SETUP}
ProgressCallback = Callable[[int, int], None]
CancelCallback = Callable[[], bool]
@@ -44,6 +47,7 @@ class UpdatePackage:
sha256: str
size: int
filename: str
type: str = PACKAGE_TYPE_ARCHIVE
@dataclass(frozen=True, slots=True)
@@ -132,36 +136,81 @@ def frozen_install_root() -> Path | None:
return executable.parent
def parse_update_offer(payload: dict[str, Any] | None, *, current_version: str) -> UpdateOffer:
def parse_update_offer(
payload: dict[str, Any] | None,
*,
current_version: str,
platform_name: str | None = None,
arch: str | None = None,
) -> UpdateOffer:
data = dict(payload or {})
expected_platform = platform_name or current_platform()
expected_arch = arch or current_arch()
response_platform = str(data.get("platform") or expected_platform).strip().lower()
response_arch = str(data.get("arch") or expected_arch).strip().lower()
package_payload = data.get("package")
package = None
if isinstance(package_payload, dict):
url = str(package_payload.get("url") or "").strip()
sha256 = str(package_payload.get("sha256") or "").strip().lower()
filename = str(package_payload.get("filename") or "").strip()
package_type = str(package_payload.get("type") or PACKAGE_TYPE_ARCHIVE).strip().lower()
try:
size = max(0, int(package_payload.get("size") or 0))
except (TypeError, ValueError):
size = 0
if url:
package = UpdatePackage(url=url, sha256=sha256, size=size, filename=filename)
has_update = bool(data.get("has_update"))
can_install = bool(data.get("can_install")) and package is not None and bool(package.sha256)
package_type_supported = package_type in SUPPORTED_PACKAGE_TYPES
package_platform_supported = (
package_type != PACKAGE_TYPE_INNO_SETUP or expected_platform == "windows"
)
if url and package_type_supported and package_platform_supported:
package = UpdatePackage(
url=url,
sha256=sha256,
size=size,
filename=filename,
type=package_type,
)
normalized_current = normalize_version(current_version) or "0.0.0"
latest_version = normalize_version(str(data.get("latest_version") or "")) or ""
response_matches = response_platform == expected_platform and response_arch == expected_arch
has_update = (
bool(data.get("has_update"))
and bool(data.get("enabled"))
and bool(latest_version)
and compare_version(normalized_current, latest_version) < 0
and response_matches
)
digest_is_valid = bool(
package is not None
and len(package.sha256) == 64
and all(character in "0123456789abcdef" for character in package.sha256)
)
installer_transport_is_valid = bool(
package is None
or package.type != PACKAGE_TYPE_INNO_SETUP
or _is_secure_installer_url(package.url)
)
can_install = (
bool(data.get("can_install"))
and package is not None
and digest_is_valid
and installer_transport_is_valid
and has_update
)
return UpdateOffer(
has_update=has_update,
force=bool(data.get("force")) and can_install,
enabled=bool(data.get("enabled")),
current_version=normalize_version(str(data.get("current_version") or current_version))
or current_version,
latest_version=normalize_version(str(data.get("latest_version") or "")) or "",
current_version=normalized_current,
latest_version=latest_version,
min_version=normalize_version(str(data.get("min_version") or "")) or "",
title=str(data.get("title") or "").strip(),
notes=str(data.get("notes") or "").strip(),
platform=str(data.get("platform") or current_platform()),
arch=str(data.get("arch") or current_arch()),
platform=response_platform,
arch=response_arch,
package=package if can_install else None,
can_install=has_update and can_install,
can_install=can_install,
)
@@ -186,7 +235,12 @@ def fetch_update_offer(
raise AppUpdateError(str(error)) from error
if payload is not None and not isinstance(payload, dict):
raise AppUpdateError("升级检测返回的数据格式不正确")
return parse_update_offer(payload if isinstance(payload, dict) else {}, current_version=version)
return parse_update_offer(
payload if isinstance(payload, dict) else {},
current_version=version,
platform_name=platform_name or current_platform(),
arch=arch or current_arch(),
)
def safe_extract_zip(archive: Path, destination: Path) -> None:
@@ -219,9 +273,7 @@ def discover_payload(extracted_root: Path, *, platform_name: str | None = None)
if not candidates:
raise AppUpdateError("安装包中未找到 DoctorWorkstation.app")
return sorted(candidates, key=lambda path: len(path.relative_to(root).parts))[0]
executables = [
path for path in root.rglob(WINDOWS_EXE_NAME) if path.is_file()
]
executables = [path for path in root.rglob(WINDOWS_EXE_NAME) if path.is_file()]
if not executables:
raise AppUpdateError("安装包中未找到 DoctorWorkstation.exe")
@@ -250,17 +302,24 @@ def download_package(
digest = (sha256 or "").strip().lower()
if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest):
raise AppUpdateError("安装包缺少有效的 SHA-256,已取消下载")
if expected_size < 0 or expected_size > MAX_PACKAGE_BYTES:
raise AppUpdateError("安装包声明的文件大小不正确")
destination.parent.mkdir(parents=True, exist_ok=True)
partial = destination.with_name(f"{destination.name}.part")
partial.unlink(missing_ok=True)
timeout = httpx.Timeout(connect=30.0, read=None, write=30.0, pool=30.0)
hasher = hashlib.sha256()
received = 0
try:
with httpx.Client(
verify=verify,
follow_redirects=True,
timeout=timeout,
transport=transport,
) as client, client.stream("GET", target) as response:
with (
httpx.Client(
verify=verify,
follow_redirects=True,
timeout=timeout,
transport=transport,
) as client,
client.stream("GET", target) as response,
):
if not 200 <= response.status_code < 300:
raise AppUpdateError(f"下载安装包失败(HTTP {response.status_code})")
try:
@@ -269,7 +328,9 @@ def download_package(
total = expected_size
if total > MAX_PACKAGE_BYTES:
raise AppUpdateError("安装包超过允许的最大体积")
with destination.open("wb") as handle:
if expected_size > 0 and total > 0 and total != expected_size:
raise AppUpdateError("安装包文件大小与发布信息不一致")
with partial.open("wb") as handle:
for chunk in response.iter_bytes(256 * 1024):
if cancelled is not None and cancelled():
raise AppUpdateError("已取消下载")
@@ -282,24 +343,61 @@ def download_package(
hasher.update(chunk)
if progress is not None:
progress(received, total)
handle.flush()
os.fsync(handle.fileno())
except AppUpdateError:
destination.unlink(missing_ok=True)
partial.unlink(missing_ok=True)
raise
except httpx.TimeoutException as error:
destination.unlink(missing_ok=True)
partial.unlink(missing_ok=True)
raise AppUpdateError("下载安装包超时") from error
except httpx.RequestError as error:
destination.unlink(missing_ok=True)
partial.unlink(missing_ok=True)
raise AppUpdateError(f"下载安装包失败:{error}") from error
if expected_size > 0 and received != expected_size:
partial.unlink(missing_ok=True)
raise AppUpdateError("安装包文件大小与发布信息不一致")
actual = hasher.hexdigest()
if actual != digest:
destination.unlink(missing_ok=True)
partial.unlink(missing_ok=True)
raise AppUpdateError("安装包校验失败,文件可能已损坏或被替换")
os.replace(partial, destination)
if progress is not None:
progress(received, received if total <= 0 else total)
return destination
def _is_secure_installer_url(url: str) -> bool:
parsed = urlsplit(url.strip())
local_hosts = {"localhost", "127.0.0.1", "::1"}
return parsed.scheme == "https" or (
parsed.scheme == "http" and (parsed.hostname or "").lower() in local_hosts
)
def validate_installer_download_policy(url: str, *, verify_ssl: bool) -> None:
"""Require authenticated transport before automatically executing an installer."""
if not verify_ssl:
raise AppUpdateError("自动安装 Windows 更新必须开启 HTTPS 证书校验")
if not _is_secure_installer_url(url):
raise AppUpdateError("自动安装 Windows 更新仅允许使用 HTTPS 下载地址")
def validate_windows_installer(installer: Path) -> Path:
candidate = installer.resolve()
if candidate.suffix.lower() != ".exe" or not candidate.is_file():
raise AppUpdateError("Windows 更新包不是有效的 Setup.exe 安装程序")
try:
with candidate.open("rb") as handle:
header = handle.read(2)
except OSError as error:
raise AppUpdateError(f"无法读取 Windows 安装程序:{error}") from error
if header != b"MZ":
raise AppUpdateError("Windows 更新包不是有效的 PE 安装程序")
return candidate
def apply_extracted_update(payload: Path, *, install_root: Path | None = None) -> None:
target_root = install_root or frozen_install_root()
if target_root is None:
@@ -309,14 +407,77 @@ def apply_extracted_update(payload: Path, *, install_root: Path | None = None) -
if not payload.exists():
raise AppUpdateError("解压后的安装包不完整")
log_file = payload.parent / "apply.log"
restart_exe = _restart_executable(payload)
restart_exe = _installed_restart_executable(target_root)
script = _write_apply_script(
payload=payload,
install_root=target_root,
restart_exe=restart_exe,
log_file=log_file,
)
_spawn_applier(script, payload=payload, install_root=target_root, restart_exe=restart_exe, log_file=log_file)
_spawn_applier(
script,
payload=payload,
install_root=target_root,
restart_exe=restart_exe,
log_file=log_file,
)
def apply_downloaded_update(
payload: Path,
*,
package_type: str,
install_root: Path | None = None,
) -> None:
if package_type == PACKAGE_TYPE_ARCHIVE:
apply_extracted_update(payload, install_root=install_root)
return
if package_type == PACKAGE_TYPE_INNO_SETUP:
apply_inno_setup_update(payload, install_root=install_root)
return
raise AppUpdateError("不支持的桌面更新包类型")
def apply_inno_setup_update(
installer: Path,
*,
install_root: Path | None = None,
) -> None:
if sys.platform != "win32":
raise AppUpdateError("Inno Setup 更新仅支持 Windows")
target_root = install_root or frozen_install_root()
if target_root is None:
raise AppUpdateError("当前为源码运行,无法自动安装 Windows 更新")
target_root = target_root.resolve()
installer = validate_windows_installer(installer)
restart_exe = _installed_restart_executable(target_root)
helper_log_file = installer.parent / "update_helper.log"
installer_log_file = installer.parent / "inno_setup.log"
try:
script = _write_inno_setup_script(
installer=installer,
restart_exe=restart_exe,
helper_log_file=helper_log_file,
installer_log_file=installer_log_file,
)
_spawn_inno_setup_applier(
script,
installer=installer,
restart_exe=restart_exe,
helper_log_file=helper_log_file,
installer_log_file=installer_log_file,
)
except OSError as error:
raise AppUpdateError(f"无法启动 Windows 更新助手:{error}") from error
def _installed_restart_executable(install_root: Path) -> Path:
if install_root.suffix == ".app" or (install_root / "Contents" / "MacOS").is_dir():
return install_root
exe = install_root / WINDOWS_EXE_NAME
if exe.is_file():
return exe
raise AppUpdateError("当前安装目录中找不到可重启的工作站程序")
def _restart_executable(payload: Path) -> Path:
@@ -352,14 +513,14 @@ def _write_apply_script(
"function Write-Log([string]$Message) {",
' Add-Content -LiteralPath $LogFile -Value ("{0} {1}" -f (Get-Date -Format o), $Message)',
"}",
"Write-Log \"waiting for pid $TargetPid\"",
'Write-Log "waiting for pid $TargetPid"',
"while (Get-Process -Id $TargetPid -ErrorAction SilentlyContinue) { Start-Sleep -Milliseconds 400 }",
"Start-Sleep -Seconds 1",
"Write-Log \"copy $Payload -> $InstallDir\"",
'Write-Log "copy $Payload -> $InstallDir"',
'$result = (Start-Process -FilePath "robocopy.exe" -ArgumentList @($Payload, $InstallDir, "/E", "/IS", "/IT", "/R:3", "/W:2", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP") -Wait -PassThru).ExitCode',
"Write-Log \"robocopy exit $result\"",
"if ($result -ge 8) { Write-Log \"copy failed\"; exit $result }",
"Write-Log \"restart $RestartExe\"",
'Write-Log "robocopy exit $result"',
'if ($result -ge 8) { Write-Log "copy failed"; exit $result }',
'Write-Log "restart $RestartExe"',
"Start-Process -FilePath $RestartExe -WorkingDirectory ([System.IO.Path]::GetDirectoryName($RestartExe))",
"exit 0",
"",
@@ -399,6 +560,115 @@ def _write_apply_script(
return script
def _write_inno_setup_script(
*,
installer: Path,
restart_exe: Path,
helper_log_file: Path,
installer_log_file: Path,
) -> Path:
script = installer.parent / "install_update.ps1"
script.write_text(
"\n".join(
[
"param(",
" [Parameter(Mandatory=$true)][int]$TargetPid,",
" [Parameter(Mandatory=$true)][string]$Installer,",
" [Parameter(Mandatory=$true)][string]$RestartExe,",
" [Parameter(Mandatory=$true)][string]$HelperLogFile,",
" [Parameter(Mandatory=$true)][string]$InstallerLogFile",
")",
'$ErrorActionPreference = "Stop"',
"function Write-Log([string]$Message) {",
' Add-Content -LiteralPath $HelperLogFile -Value ("{0} {1}" -f (Get-Date -Format o), $Message)',
"}",
"function Restart-Application {",
" if (Test-Path -LiteralPath $RestartExe -PathType Leaf) {",
' Write-Log "restart $RestartExe"',
" Start-Process -FilePath $RestartExe -WorkingDirectory ([System.IO.Path]::GetDirectoryName($RestartExe))",
" } else {",
' Write-Log "restart executable missing: $RestartExe"',
" }",
"}",
'Write-Log "waiting for pid $TargetPid"',
"while (Get-Process -Id $TargetPid -ErrorAction SilentlyContinue) { Start-Sleep -Milliseconds 400 }",
"Start-Sleep -Milliseconds 600",
"$arguments = @(",
' "/VERYSILENT",',
' "/SUPPRESSMSGBOXES",',
' "/SP-",',
' "/NORESTART",',
' "/RESTARTEXITCODE=3010",',
' "/CLOSEAPPLICATIONS",',
' "/NOFORCECLOSEAPPLICATIONS",',
' "/NORESTARTAPPLICATIONS",',
' ("/LOG=`"{0}`"" -f $InstallerLogFile)',
")",
"try {",
' Write-Log "launch installer $Installer"',
" $process = Start-Process -FilePath $Installer -ArgumentList $arguments -Wait -PassThru -WindowStyle Hidden",
" $exitCode = $process.ExitCode",
' Write-Log "installer exit $exitCode"',
"} catch {",
' Write-Log ("installer launch failed: {0}" -f $_.Exception.Message)',
" Restart-Application",
" exit 1",
"}",
"if ($exitCode -eq 0 -or $exitCode -eq 3010) {",
' Write-Log "installation completed"',
" Restart-Application",
" exit 0",
"}",
'Write-Log "installation failed or was cancelled; restarting existing application"',
"Restart-Application",
"exit $exitCode",
"",
]
),
encoding="utf-8-sig",
)
del restart_exe, helper_log_file, installer_log_file
return script
def _spawn_inno_setup_applier(
script: Path,
*,
installer: Path,
restart_exe: Path,
helper_log_file: Path,
installer_log_file: Path,
) -> None:
flags = getattr(subprocess, "DETACHED_PROCESS", 0)
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0)
subprocess.Popen(
[
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-WindowStyle",
"Hidden",
"-File",
str(script),
"-TargetPid",
str(os.getpid()),
"-Installer",
str(installer),
"-RestartExe",
str(restart_exe),
"-HelperLogFile",
str(helper_log_file),
"-InstallerLogFile",
str(installer_log_file),
],
close_fds=True,
creationflags=flags,
cwd=str(script.parent),
)
def _spawn_applier(
script: Path,
*,
@@ -459,5 +729,6 @@ def prepare_update_workspace(config_dir: Path, version: str) -> Path:
def package_filename(package: UpdatePackage, version: str) -> str:
name = Path(package.filename or urlsplit(package.url).path).name
if not name:
name = f"DoctorWorkstation-{version}.zip"
suffix = ".exe" if package.type == PACKAGE_TYPE_INNO_SETUP else ".zip"
name = f"DoctorWorkstation-{version}{suffix}"
return name
@@ -7,7 +7,7 @@ import os
import sys
import traceback
from collections.abc import Callable
from dataclasses import replace
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any
@@ -26,9 +26,10 @@ from PySide6.QtWidgets import (
)
from doctor_workstation.services.app_update import (
PACKAGE_TYPE_INNO_SETUP,
AppUpdateError,
UpdateOffer,
apply_extracted_update,
apply_downloaded_update,
current_app_version,
discover_payload,
download_package,
@@ -38,12 +39,20 @@ from doctor_workstation.services.app_update import (
package_filename,
prepare_update_workspace,
safe_extract_zip,
validate_installer_download_policy,
validate_windows_installer,
)
from doctor_workstation.ui.widgets import friendly_error, show_toast
LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class _PreparedUpdate:
package_type: str
payload: Path
def _format_bytes(value: int) -> str:
size = max(0, int(value))
if size < 1024:
@@ -100,7 +109,11 @@ class AppUpdateDialog(QDialog):
self.setWindowTitle(title)
self.setModal(True)
self.setMinimumWidth(520)
flags = Qt.WindowType.Dialog | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowSystemMenuHint
flags = (
Qt.WindowType.Dialog
| Qt.WindowType.WindowTitleHint
| Qt.WindowType.WindowSystemMenuHint
)
if not offer.force:
flags |= Qt.WindowType.WindowCloseButtonHint
self.setWindowFlags(flags)
@@ -350,10 +363,19 @@ class AppUpdateSession(QObject):
current = offer.current_version or current_app_version()
show_toast(parent, f"当前已是最新版本 {current}。", "success")
return
if not offer.force and not interactive and offer.latest_version in self._dismissed:
return
if offer.force and not is_frozen_install():
offer = replace(offer, force=False)
if not offer.force and not interactive and offer.latest_version in self._dismissed:
return
if offer.package is not None and offer.package.type == PACKAGE_TYPE_INNO_SETUP:
config = getattr(self.host, "config", None)
try:
validate_installer_download_policy(
offer.package.url,
verify_ssl=bool(getattr(config, "verify_ssl", True)),
)
except AppUpdateError:
offer = replace(offer, force=False, package=None, can_install=False)
if offer.force and not is_frozen_install():
offer = replace(offer, force=False)
self._present(offer)
def _present(self, offer: UpdateOffer) -> None:
@@ -381,7 +403,7 @@ class AppUpdateSession(QObject):
if package is None:
return
if not is_frozen_install():
dialog.show_error("当前为源码运行,无法自动安装。请使用发布 ZIP 安装后再更新。")
dialog.show_error("当前为源码运行,无法自动安装。请先安装正式发布版后再更新。")
return
if frozen_install_root() is None:
dialog.show_error("无法确定当前安装目录,已取消自动更新。")
@@ -398,27 +420,38 @@ class AppUpdateSession(QObject):
signals = _TaskSignals()
self._signals = signals
def job() -> Path:
def job() -> _PreparedUpdate:
workspace = prepare_update_workspace(config_dir, offer.latest_version)
archive = workspace / package_filename(package, offer.latest_version)
downloaded = workspace / package_filename(package, offer.latest_version)
if package.type == PACKAGE_TYPE_INNO_SETUP:
validate_installer_download_policy(package.url, verify_ssl=verify)
download_package(
package.url,
archive,
downloaded,
sha256=package.sha256,
verify=verify,
expected_size=package.size,
progress=lambda received, total: signals.progress.emit(received, total),
cancelled=lambda: self._cancel,
)
if package.type == PACKAGE_TYPE_INNO_SETUP:
signals.status.emit("正在校验 Windows 安装程序…")
return _PreparedUpdate(
package_type=package.type,
payload=validate_windows_installer(downloaded),
)
signals.status.emit("正在校验并解压安装包…")
extracted = workspace / "payload"
safe_extract_zip(archive, extracted)
return discover_payload(extracted)
safe_extract_zip(downloaded, extracted)
return _PreparedUpdate(
package_type=package.type,
payload=discover_payload(extracted),
)
worker = _Task(job, signals)
signals.progress.connect(dialog.show_download_progress)
signals.status.connect(dialog.show_status)
signals.result.connect(lambda payload: self._finish_install(dialog, payload))
signals.result.connect(lambda prepared: self._finish_install(dialog, prepared))
signals.error.connect(lambda error, _tb: self._install_failed(dialog, error))
QThreadPool.globalInstance().start(worker)
@@ -431,13 +464,21 @@ class AppUpdateSession(QObject):
message = str(error) if isinstance(error, AppUpdateError) else friendly_error(error)
dialog.show_error(f"更新失败:{message}")
def _finish_install(self, dialog: AppUpdateDialog, payload: object) -> None:
if not isinstance(payload, Path):
dialog.show_error("安装包解压结果无效。")
def _finish_install(self, dialog: AppUpdateDialog, prepared: object) -> None:
if not isinstance(prepared, _PreparedUpdate):
dialog.show_error("安装包准备结果无效。")
return
dialog.show_status("即将关闭并完成安装…", determinate=True)
message = (
"即将关闭程序并自动安装,系统可能会请求管理员权限…"
if prepared.package_type == PACKAGE_TYPE_INNO_SETUP
else "即将关闭并完成安装…"
)
dialog.show_status(message, determinate=True)
try:
apply_extracted_update(payload)
apply_downloaded_update(
prepared.payload,
package_type=prepared.package_type,
)
except AppUpdateError as error:
dialog.show_error(str(error))
return
+14 -51
View File
@@ -41,6 +41,7 @@ from PySide6.QtWidgets import (
)
from doctor_workstation import __version__
from doctor_workstation.resources import app_icon_path, brand_lockup_path
from .theme import crisp_pixmap
from .widgets import BusyOverlay, MessageBanner, friendly_error, invoke, run_async
@@ -134,47 +135,7 @@ class _BrandPanel(QWidget):
self.setMinimumWidth(320)
self.setMaximumWidth(824)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
@staticmethod
def _draw_mark(painter: QPainter, rect: QRectF, *, faint: bool = False) -> None:
alpha = 112 if faint else 255
gradient = QLinearGradient(rect.topLeft(), rect.bottomRight())
gradient.setColorAt(0.0, QColor(122, 137, 255, alpha))
gradient.setColorAt(1.0, QColor(71, 82, 238, alpha))
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(gradient)
painter.drawRoundedRect(rect, rect.width() * 0.27, rect.width() * 0.27)
pen = QPen(
QColor(255, 255, 255, 235 if not faint else 150),
max(2.0, rect.width() / 24),
)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
bag = QRectF(
rect.left() + rect.width() * 0.24,
rect.top() + rect.height() * 0.35,
rect.width() * 0.52,
rect.height() * 0.38,
)
painter.drawRoundedRect(bag, rect.width() * 0.06, rect.width() * 0.06)
handle = QRectF(
rect.left() + rect.width() * 0.39,
rect.top() + rect.height() * 0.25,
rect.width() * 0.22,
rect.height() * 0.15,
)
painter.drawRoundedRect(handle, rect.width() * 0.04, rect.width() * 0.04)
cx, cy = rect.center().x(), rect.top() + rect.height() * 0.54
painter.drawLine(
QPointF(cx - rect.width() * 0.13, cy), QPointF(cx + rect.width() * 0.13, cy)
)
painter.drawLine(
QPointF(cx, cy - rect.height() * 0.13),
QPointF(cx, cy + rect.height() * 0.13),
)
self._brand_lockup = QPixmap(str(brand_lockup_path()))
@staticmethod
def _draw_cube(painter: QPainter, center: QPointF, size: float, kind: str) -> None:
@@ -340,10 +301,17 @@ class _BrandPanel(QWidget):
self._draw_illustration(painter, width, height)
left = 64.0 if width >= 620 else 38.0
self._draw_mark(painter, QRectF(left, 51, 70, 70))
painter.setPen(QColor("#14224A"))
painter.setFont(_font(28, QFont.Weight.Bold))
painter.drawText(QPointF(left + 93, 98), "甄养堂医疗")
if not self._brand_lockup.isNull():
logo_width = 180.0
logo_height = logo_width * (
self._brand_lockup.height() / self._brand_lockup.width()
)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
painter.drawPixmap(
QRectF(left, 24, logo_width, logo_height),
self._brand_lockup,
QRectF(self._brand_lockup.rect()),
)
tag_rect = QRectF(left, 238, 119, 40)
painter.setPen(Qt.PenStyle.NoPen)
@@ -919,12 +887,7 @@ class LoginWindow(QMainWindow):
@staticmethod
def _window_icon() -> QIcon:
pixmap = crisp_pixmap(64)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
_BrandPanel._draw_mark(painter, QRectF(1, 1, 62, 62))
painter.end()
return QIcon(pixmap)
return QIcon(str(app_icon_path()))
@staticmethod
def _account_icon() -> QIcon:
+11 -16
View File
@@ -14,7 +14,6 @@ from PySide6.QtGui import (
QFont,
QIcon,
QKeySequence,
QLinearGradient,
QMouseEvent,
QPainter,
QPen,
@@ -40,7 +39,8 @@ from PySide6.QtWidgets import (
QWidget,
)
from .theme import crisp_pixmap
from doctor_workstation.resources import app_icon_path
from .dialogs.ai_consult import can_open_ai_consult
from .dialogs.ai_consult_picker import select_and_present_ai_consult
from .dialogs.local_audio_queue import LocalAudioQueueDialog
@@ -52,6 +52,7 @@ from .pages import (
PrescriptionsPage,
ReceptionPage,
)
from .theme import crisp_pixmap
from .widgets import (
EmptyState,
StatusBadge,
@@ -575,25 +576,19 @@ def _painted_navigation_icon(kind: str, size: int = 18) -> QIcon:
class _ShellBrandMark(QWidget):
"""Paint the supplied indigo M mark without a font-glyph asset."""
"""Render the approved application brand mark in the navigation rail."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._brand_pixmap = QPixmap(str(app_icon_path()))
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
del event
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
rect = QRectF(self.rect()).adjusted(0.75, 0.75, -0.75, -0.75)
gradient = QLinearGradient(rect.topLeft(), rect.bottomRight())
gradient.setColorAt(0.0, QColor("#6F7BFA"))
gradient.setColorAt(1.0, QColor("#5260ED"))
painter.setPen(QPen(QColor(117, 130, 255, 150), 1.0))
painter.setBrush(gradient)
painter.drawRoundedRect(rect, 10, 10)
painter.setPen(QColor("#FFFFFF"))
font = QFont(painter.font())
font.setPixelSize(18)
font.setWeight(QFont.Weight.Bold)
painter.setFont(font)
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "M")
if not self._brand_pixmap.isNull():
painter.drawPixmap(rect, self._brand_pixmap, QRectF(self._brand_pixmap.rect()))
class _AssistantRobot(QWidget):