更新
This commit is contained in:
@@ -34,6 +34,7 @@ from doctor_workstation.services import (
|
||||
build_repository,
|
||||
)
|
||||
from doctor_workstation.ui import LoginWindow, ShellWindow, apply_theme
|
||||
from doctor_workstation.ui.dialogs.app_update import AppUpdateSession
|
||||
from doctor_workstation.ui.widgets import (
|
||||
friendly_error,
|
||||
run_async,
|
||||
@@ -255,6 +256,7 @@ class ApplicationController(QObject):
|
||||
self._shutting_down = False
|
||||
self._authentication_expiry_in_progress = False
|
||||
self._rebuild_remote_repository()
|
||||
self.app_updater = AppUpdateSession(self)
|
||||
set_authentication_expired_handler(self._on_authentication_expired)
|
||||
application.aboutToQuit.connect(self.shutdown)
|
||||
|
||||
@@ -263,6 +265,7 @@ class ApplicationController(QObject):
|
||||
|
||||
self._show_login()
|
||||
self._begin_session_restore()
|
||||
self.app_updater.schedule()
|
||||
|
||||
def _base_repository(self) -> Any:
|
||||
return self.remote_repository or _UnconfiguredRepository()
|
||||
@@ -333,6 +336,7 @@ class ApplicationController(QObject):
|
||||
self.login_window.repository = self._base_repository()
|
||||
if not self.login_window.demo_check.isChecked():
|
||||
self.login_window.active_repository = self._base_repository()
|
||||
self.app_updater.schedule()
|
||||
if self.login_window is not None:
|
||||
self.login_window.config = self.config
|
||||
|
||||
@@ -512,12 +516,16 @@ class ApplicationController(QObject):
|
||||
self.shell_window = ShellWindow(repository, payload, session.permissions)
|
||||
self.shell_window.logout_requested.connect(self._logout)
|
||||
self.shell_window.video_requested.connect(self._request_video)
|
||||
self.shell_window.update_check_requested.connect(
|
||||
lambda: self.app_updater.check(interactive=True)
|
||||
)
|
||||
self._apply_window_icon(self.shell_window)
|
||||
if self.login_window is not None:
|
||||
self.login_window.hide()
|
||||
self.shell_window.show()
|
||||
self.shell_window.raise_()
|
||||
self.shell_window.activateWindow()
|
||||
self.app_updater.schedule(delay_ms=400)
|
||||
|
||||
def _login_guard_error(self, message: str) -> None:
|
||||
if self.login_window is not None:
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
"""Detect, download and apply doctor-workstation desktop updates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import zipfile
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
from doctor_workstation import __version__
|
||||
from doctor_workstation.core.errors import (
|
||||
ApiHttpError,
|
||||
ApiTimeoutError,
|
||||
ApiTransportError,
|
||||
)
|
||||
from doctor_workstation.services.api_client import ApiClient
|
||||
|
||||
CHECK_ENDPOINT = "setting.desktop_workstation/check"
|
||||
MAX_PACKAGE_BYTES = 2 * 1024 * 1024 * 1024
|
||||
WINDOWS_EXE_NAME = "DoctorWorkstation.exe"
|
||||
MACOS_APP_NAME = "DoctorWorkstation.app"
|
||||
ProgressCallback = Callable[[int, int], None]
|
||||
CancelCallback = Callable[[], bool]
|
||||
|
||||
|
||||
class AppUpdateError(RuntimeError):
|
||||
"""A desktop update could not be checked, downloaded or applied."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UpdatePackage:
|
||||
url: str
|
||||
sha256: str
|
||||
size: int
|
||||
filename: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UpdateOffer:
|
||||
has_update: bool
|
||||
force: bool
|
||||
enabled: bool
|
||||
current_version: str
|
||||
latest_version: str
|
||||
min_version: str
|
||||
title: str
|
||||
notes: str
|
||||
platform: str
|
||||
arch: str
|
||||
package: UpdatePackage | None
|
||||
can_install: bool
|
||||
|
||||
|
||||
def current_app_version() -> str:
|
||||
return normalize_version(__version__) or "0.0.0"
|
||||
|
||||
|
||||
def is_frozen_install() -> bool:
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def current_platform() -> str:
|
||||
if sys.platform == "win32":
|
||||
return "windows"
|
||||
if sys.platform == "darwin":
|
||||
return "macos"
|
||||
return sys.platform
|
||||
|
||||
|
||||
def current_arch() -> str:
|
||||
machine = platform.machine().lower()
|
||||
if machine in {"amd64", "x86_64", "x64"}:
|
||||
return "x64"
|
||||
if machine in {"arm64", "aarch64"}:
|
||||
return "arm64"
|
||||
return machine
|
||||
|
||||
|
||||
def normalize_version(value: str | None) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
parts = []
|
||||
for raw in text.split("."):
|
||||
if not raw.isdigit():
|
||||
return ""
|
||||
parts.append(str(int(raw)))
|
||||
if len(parts) == 3:
|
||||
break
|
||||
if not parts:
|
||||
return ""
|
||||
while len(parts) < 3:
|
||||
parts.append("0")
|
||||
return ".".join(parts)
|
||||
|
||||
|
||||
def compare_version(left: str, right: str) -> int:
|
||||
def parts(value: str) -> tuple[int, int, int]:
|
||||
normalized = normalize_version(value)
|
||||
if not normalized:
|
||||
return (0, 0, 0)
|
||||
numbers = [int(item) for item in normalized.split(".")]
|
||||
return (numbers[0], numbers[1], numbers[2])
|
||||
|
||||
left_parts = parts(left)
|
||||
right_parts = parts(right)
|
||||
return (left_parts > right_parts) - (left_parts < right_parts)
|
||||
|
||||
|
||||
def frozen_install_root() -> Path | None:
|
||||
if not is_frozen_install():
|
||||
return None
|
||||
executable = Path(sys.executable).resolve()
|
||||
if sys.platform == "darwin":
|
||||
macos_dir = executable.parent
|
||||
contents = macos_dir.parent
|
||||
bundle = contents.parent
|
||||
if macos_dir.name == "MacOS" and contents.name == "Contents" and bundle.suffix == ".app":
|
||||
return bundle
|
||||
return macos_dir
|
||||
return executable.parent
|
||||
|
||||
|
||||
def parse_update_offer(payload: dict[str, Any] | None, *, current_version: str) -> UpdateOffer:
|
||||
data = dict(payload or {})
|
||||
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()
|
||||
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)
|
||||
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 "",
|
||||
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()),
|
||||
package=package if can_install else None,
|
||||
can_install=has_update and can_install,
|
||||
)
|
||||
|
||||
|
||||
def fetch_update_offer(
|
||||
client: ApiClient,
|
||||
*,
|
||||
current_version: str | None = None,
|
||||
platform_name: str | None = None,
|
||||
arch: str | None = None,
|
||||
) -> UpdateOffer:
|
||||
version = normalize_version(current_version or current_app_version()) or "0.0.0"
|
||||
try:
|
||||
payload = client.get(
|
||||
CHECK_ENDPOINT,
|
||||
{
|
||||
"current_version": version,
|
||||
"platform": platform_name or current_platform(),
|
||||
"arch": arch or current_arch(),
|
||||
},
|
||||
)
|
||||
except (ApiTimeoutError, ApiTransportError, ApiHttpError) as error:
|
||||
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)
|
||||
|
||||
|
||||
def safe_extract_zip(archive: Path, destination: Path) -> None:
|
||||
destination = destination.resolve()
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(archive) as bundle:
|
||||
for info in bundle.infolist():
|
||||
name = info.filename.replace("\\", "/")
|
||||
if name.startswith("/") or (len(name) > 1 and name[1] == ":"):
|
||||
raise AppUpdateError("安装包包含非法路径,已拒绝解压")
|
||||
target = Path(os.path.normpath(destination / name))
|
||||
try:
|
||||
target.relative_to(destination)
|
||||
except ValueError as error:
|
||||
raise AppUpdateError("安装包包含非法路径,已拒绝解压") from error
|
||||
bundle.extractall(destination)
|
||||
|
||||
|
||||
def discover_payload(extracted_root: Path, *, platform_name: str | None = None) -> Path:
|
||||
root = extracted_root.resolve()
|
||||
os_name = platform_name or current_platform()
|
||||
if os_name == "macos":
|
||||
apps = [
|
||||
path
|
||||
for path in root.rglob("*.app")
|
||||
if path.is_dir() and (path / "Contents" / "MacOS").is_dir()
|
||||
]
|
||||
named = [path for path in apps if path.name == MACOS_APP_NAME]
|
||||
candidates = named or apps
|
||||
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()
|
||||
]
|
||||
if not executables:
|
||||
raise AppUpdateError("安装包中未找到 DoctorWorkstation.exe")
|
||||
|
||||
def score(path: Path) -> tuple[int, int]:
|
||||
has_internal = 0 if (path.parent / "_internal").is_dir() else 1
|
||||
return (has_internal, len(path.relative_to(root).parts))
|
||||
|
||||
return sorted(executables, key=score)[0].parent
|
||||
|
||||
|
||||
def download_package(
|
||||
url: str,
|
||||
destination: Path,
|
||||
*,
|
||||
sha256: str,
|
||||
verify: bool = True,
|
||||
expected_size: int = 0,
|
||||
progress: ProgressCallback | None = None,
|
||||
cancelled: CancelCallback | None = None,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> Path:
|
||||
target = url.strip()
|
||||
parsed = urlsplit(target)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
raise AppUpdateError("安装包地址无效")
|
||||
digest = (sha256 or "").strip().lower()
|
||||
if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest):
|
||||
raise AppUpdateError("安装包缺少有效的 SHA-256,已取消下载")
|
||||
destination.parent.mkdir(parents=True, exist_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:
|
||||
if not 200 <= response.status_code < 300:
|
||||
raise AppUpdateError(f"下载安装包失败(HTTP {response.status_code})")
|
||||
try:
|
||||
total = int(response.headers.get("content-length") or expected_size or 0)
|
||||
except (TypeError, ValueError):
|
||||
total = expected_size
|
||||
if total > MAX_PACKAGE_BYTES:
|
||||
raise AppUpdateError("安装包超过允许的最大体积")
|
||||
with destination.open("wb") as handle:
|
||||
for chunk in response.iter_bytes(256 * 1024):
|
||||
if cancelled is not None and cancelled():
|
||||
raise AppUpdateError("已取消下载")
|
||||
if not chunk:
|
||||
continue
|
||||
received += len(chunk)
|
||||
if received > MAX_PACKAGE_BYTES:
|
||||
raise AppUpdateError("安装包超过允许的最大体积")
|
||||
handle.write(chunk)
|
||||
hasher.update(chunk)
|
||||
if progress is not None:
|
||||
progress(received, total)
|
||||
except AppUpdateError:
|
||||
destination.unlink(missing_ok=True)
|
||||
raise
|
||||
except httpx.TimeoutException as error:
|
||||
destination.unlink(missing_ok=True)
|
||||
raise AppUpdateError("下载安装包超时") from error
|
||||
except httpx.RequestError as error:
|
||||
destination.unlink(missing_ok=True)
|
||||
raise AppUpdateError(f"下载安装包失败:{error}") from error
|
||||
actual = hasher.hexdigest()
|
||||
if actual != digest:
|
||||
destination.unlink(missing_ok=True)
|
||||
raise AppUpdateError("安装包校验失败,文件可能已损坏或被替换")
|
||||
if progress is not None:
|
||||
progress(received, received if total <= 0 else total)
|
||||
return destination
|
||||
|
||||
|
||||
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:
|
||||
raise AppUpdateError("当前为源码运行,无法自动替换安装目录")
|
||||
payload = payload.resolve()
|
||||
target_root = target_root.resolve()
|
||||
if not payload.exists():
|
||||
raise AppUpdateError("解压后的安装包不完整")
|
||||
log_file = payload.parent / "apply.log"
|
||||
restart_exe = _restart_executable(payload)
|
||||
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)
|
||||
|
||||
|
||||
def _restart_executable(payload: Path) -> Path:
|
||||
if payload.suffix == ".app" or (payload / "Contents" / "MacOS").is_dir():
|
||||
return payload
|
||||
exe = payload / WINDOWS_EXE_NAME
|
||||
if exe.is_file():
|
||||
return exe
|
||||
raise AppUpdateError("解压结果中找不到可启动的工作站程序")
|
||||
|
||||
|
||||
def _write_apply_script(
|
||||
*,
|
||||
payload: Path,
|
||||
install_root: Path,
|
||||
restart_exe: Path,
|
||||
log_file: Path,
|
||||
) -> Path:
|
||||
directory = payload.parent
|
||||
if sys.platform == "win32":
|
||||
script = directory / "apply_update.ps1"
|
||||
script.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"param(",
|
||||
" [Parameter(Mandatory=$true)][int]$TargetPid,",
|
||||
" [Parameter(Mandatory=$true)][string]$Payload,",
|
||||
" [Parameter(Mandatory=$true)][string]$InstallDir,",
|
||||
" [Parameter(Mandatory=$true)][string]$RestartExe,",
|
||||
" [Parameter(Mandatory=$true)][string]$LogFile",
|
||||
")",
|
||||
'$ErrorActionPreference = "Continue"',
|
||||
"function Write-Log([string]$Message) {",
|
||||
' Add-Content -LiteralPath $LogFile -Value ("{0} {1}" -f (Get-Date -Format o), $Message)',
|
||||
"}",
|
||||
"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\"",
|
||||
'$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\"",
|
||||
"Start-Process -FilePath $RestartExe -WorkingDirectory ([System.IO.Path]::GetDirectoryName($RestartExe))",
|
||||
"exit 0",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return script
|
||||
script = directory / "apply_update.sh"
|
||||
script.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"#!/bin/bash",
|
||||
"set -eu",
|
||||
'TARGET_PID="$1"',
|
||||
'PAYLOAD="$2"',
|
||||
'INSTALL_DIR="$3"',
|
||||
'LOG_FILE="$4"',
|
||||
'log() { printf "%s %s\\n" "$(date -Iseconds 2>/dev/null || date)" "$1" >>"$LOG_FILE"; }',
|
||||
'log "waiting for pid $TARGET_PID"',
|
||||
'while kill -0 "$TARGET_PID" 2>/dev/null; do sleep 0.3; done',
|
||||
"sleep 1",
|
||||
'log "replace $INSTALL_DIR with $PAYLOAD"',
|
||||
'TMP_DIR="${INSTALL_DIR}.next"',
|
||||
'rm -rf "$TMP_DIR"',
|
||||
'if command -v ditto >/dev/null 2>&1; then ditto "$PAYLOAD" "$TMP_DIR"; else cp -R "$PAYLOAD" "$TMP_DIR"; fi',
|
||||
'rm -rf "$INSTALL_DIR"',
|
||||
'mv "$TMP_DIR" "$INSTALL_DIR"',
|
||||
'log "open $INSTALL_DIR"',
|
||||
'open "$INSTALL_DIR"',
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
script.chmod(script.stat().st_mode | stat.S_IEXEC)
|
||||
return script
|
||||
|
||||
|
||||
def _spawn_applier(
|
||||
script: Path,
|
||||
*,
|
||||
payload: Path,
|
||||
install_root: Path,
|
||||
restart_exe: Path,
|
||||
log_file: Path,
|
||||
) -> None:
|
||||
pid = os.getpid()
|
||||
if sys.platform == "win32":
|
||||
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(pid),
|
||||
"-Payload",
|
||||
str(payload),
|
||||
"-InstallDir",
|
||||
str(install_root),
|
||||
"-RestartExe",
|
||||
str(restart_exe),
|
||||
"-LogFile",
|
||||
str(log_file),
|
||||
],
|
||||
close_fds=True,
|
||||
creationflags=flags,
|
||||
cwd=str(script.parent),
|
||||
)
|
||||
return
|
||||
subprocess.Popen(
|
||||
["/bin/bash", str(script), str(pid), str(payload), str(install_root), str(log_file)],
|
||||
start_new_session=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
cwd=str(script.parent),
|
||||
)
|
||||
del restart_exe
|
||||
|
||||
|
||||
def prepare_update_workspace(config_dir: Path, version: str) -> Path:
|
||||
workspace = config_dir / "updates" / normalize_version(version).replace(".", "_")
|
||||
if workspace.exists():
|
||||
shutil.rmtree(workspace, ignore_errors=True)
|
||||
workspace.mkdir(parents=True, exist_ok=True)
|
||||
return workspace
|
||||
|
||||
|
||||
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"
|
||||
return name
|
||||
@@ -28,9 +28,10 @@ from doctor_workstation.core.models import (
|
||||
from doctor_workstation.core.permissions import PermissionSet
|
||||
from doctor_workstation.core.session import Session
|
||||
|
||||
from .repository import (
|
||||
AuditAction,
|
||||
_audit_action,
|
||||
from .repository import (
|
||||
AuditAction,
|
||||
_ai_patient_option,
|
||||
_audit_action,
|
||||
_body,
|
||||
_daily_record_body,
|
||||
_identified_body,
|
||||
@@ -1049,10 +1050,11 @@ class DemoDoctorRepository:
|
||||
if len(clean_prompt) > 500:
|
||||
raise ValueError("prompt must not exceed 500 characters")
|
||||
if task not in {
|
||||
"summary",
|
||||
"tcm_pattern",
|
||||
"prescription_review",
|
||||
"medication_review",
|
||||
"summary",
|
||||
"tcm_pattern",
|
||||
"prescription_review",
|
||||
"prescription_generate",
|
||||
"medication_review",
|
||||
"exam_review",
|
||||
"complication_risk",
|
||||
"guideline_review",
|
||||
@@ -1074,8 +1076,54 @@ class DemoDoctorRepository:
|
||||
),
|
||||
"model_key": model_key,
|
||||
"model_label": "千问" if model_key == "qwen" else "OpenAI",
|
||||
"task": task,
|
||||
}
|
||||
"task": task,
|
||||
}
|
||||
|
||||
def generate_ai_prescription(self, diagnosis_id: int) -> dict[str, Any]:
|
||||
"""Return a deterministic full-context prescription draft in demo mode."""
|
||||
|
||||
with self._lock:
|
||||
consultation = self._find_consultation(diagnosis_id)
|
||||
raw = consultation.raw if isinstance(consultation.raw, Mapping) else {}
|
||||
clinical = str(
|
||||
raw.get("clinical_diagnosis") or raw.get("symptoms") or "待辨证"
|
||||
).strip()
|
||||
draft = {
|
||||
"prescription_name": "AI辨证处方草稿",
|
||||
"clinical_diagnosis": clinical,
|
||||
"prescription_type": "饮片",
|
||||
"tongue": str(raw.get("tongue") or raw.get("tongue_coating") or ""),
|
||||
"tongue_image": "",
|
||||
"pulse": str(raw.get("pulse") or ""),
|
||||
"pulse_condition": str(raw.get("pulse_condition") or ""),
|
||||
"herbs": [
|
||||
{"name": "黄芪", "dosage": 15.0, "formula_type": "主方"},
|
||||
{"name": "山药", "dosage": 15.0, "formula_type": "主方"},
|
||||
],
|
||||
"dose_count": 7,
|
||||
"dose_unit": "剂",
|
||||
"usage_days": 7,
|
||||
"times_per_day": 2,
|
||||
"usage_instruction": "水煎服,一日二次",
|
||||
"usage_time": "饭后",
|
||||
"usage_way": "温服",
|
||||
"dietary_taboo": ["辛辣食物", "生冷食物"],
|
||||
"usage_notes": "请结合舌脉与最新检查逐味复核。",
|
||||
"rationale": "已依据患者纵向资料生成演示草稿,请医生逐项复核并签名。",
|
||||
"risk_warnings": "演示数据不可替代真实临床判断。",
|
||||
"requires_doctor_review": True,
|
||||
"audit_status": 0,
|
||||
}
|
||||
return {
|
||||
"diagnosis_id": consultation.id,
|
||||
"answer": draft["rationale"],
|
||||
"model_key": "qwen",
|
||||
"model_label": "千问",
|
||||
"task": "prescription_generate",
|
||||
"context_scope": "patient_longitudinal",
|
||||
"context_version": "patient-context-assistant-v2",
|
||||
"prescription_draft": draft,
|
||||
}
|
||||
|
||||
def get_diagnosis_ai_analysis(
|
||||
self,
|
||||
@@ -1657,9 +1705,9 @@ class DemoDoctorRepository:
|
||||
}
|
||||
return deepcopy(dictionaries.get(dictionary_type, []))
|
||||
|
||||
def list_patients(
|
||||
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
|
||||
) -> PageResult[Patient]:
|
||||
def list_patients(
|
||||
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
|
||||
) -> PageResult[Patient]:
|
||||
"""Return doctor-scoped demo patients and summary extension data."""
|
||||
|
||||
with self._lock:
|
||||
@@ -1695,17 +1743,78 @@ class DemoDoctorRepository:
|
||||
),
|
||||
"day_after": 0,
|
||||
}
|
||||
return _page(
|
||||
rows,
|
||||
page_no,
|
||||
page_size,
|
||||
return _page(
|
||||
rows,
|
||||
page_no,
|
||||
page_size,
|
||||
{
|
||||
"summary": summary,
|
||||
"scope": {"label": "演示数据(仅本机内存)"},
|
||||
},
|
||||
)
|
||||
|
||||
def patient_orders(
|
||||
},
|
||||
)
|
||||
|
||||
def list_ai_patient_options(
|
||||
self, *, page_no: int = 1, page_size: int = 20, keyword: str = ""
|
||||
) -> PageResult[dict[str, Any]]:
|
||||
"""Return deterministic diagnosis choices without plaintext patient data."""
|
||||
|
||||
clean_keyword = str(keyword or "").strip().lower()
|
||||
with self._lock:
|
||||
consultations = {row.id: row for row in self._consultations}
|
||||
patients = sorted(
|
||||
self._patients,
|
||||
key=lambda row: (row.diagnosis_id or row.id, row.source_patient_id or 0),
|
||||
reverse=True,
|
||||
)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for patient in patients:
|
||||
diagnosis_id = patient.diagnosis_id or patient.id
|
||||
consultation = consultations.get(diagnosis_id)
|
||||
searchable = " ".join(
|
||||
(
|
||||
patient.name,
|
||||
patient.phone,
|
||||
patient.phone_masked,
|
||||
str(diagnosis_id),
|
||||
str(patient.source_patient_id or ""),
|
||||
consultation.clinical_diagnosis if consultation else "",
|
||||
str(consultation.raw.get("syndrome_type") or "")
|
||||
if consultation
|
||||
else "",
|
||||
)
|
||||
).lower()
|
||||
if clean_keyword and clean_keyword not in searchable:
|
||||
continue
|
||||
diagnosis_date = consultation.diagnosis_date if consultation else ""
|
||||
next_appointment_at = patient.appointment_time_text
|
||||
rows.append(
|
||||
_ai_patient_option(
|
||||
{
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"source_patient_id": patient.source_patient_id,
|
||||
"patient_name": patient.name,
|
||||
"phone_masked": patient.phone_masked,
|
||||
"gender": patient.gender,
|
||||
"gender_desc": patient.gender_desc,
|
||||
"age": patient.age,
|
||||
"diagnosis_date": diagnosis_date,
|
||||
"diagnosis_summary": (
|
||||
consultation.clinical_diagnosis if consultation else ""
|
||||
),
|
||||
"syndrome_type": (
|
||||
consultation.raw.get("syndrome_type") if consultation else ""
|
||||
),
|
||||
"clinical_diagnosis": (
|
||||
consultation.clinical_diagnosis if consultation else ""
|
||||
),
|
||||
"last_visit_at": diagnosis_date,
|
||||
"next_appointment_at": next_appointment_at,
|
||||
}
|
||||
)
|
||||
)
|
||||
return _page(rows, page_no, page_size)
|
||||
|
||||
def patient_orders(
|
||||
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
|
||||
) -> PageResult[dict[str, Any]]:
|
||||
"""Return the scoped demo order workspace and its summary extension."""
|
||||
|
||||
@@ -98,10 +98,15 @@ class DoctorRepository(Protocol):
|
||||
) -> PageResult[Patient]:
|
||||
"""Return a doctor-scoped patient page."""
|
||||
|
||||
def list_consultations(
|
||||
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
|
||||
) -> PageResult[Consultation]:
|
||||
"""Return a diagnosis/consultation page."""
|
||||
def list_consultations(
|
||||
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
|
||||
) -> PageResult[Consultation]:
|
||||
"""Return a diagnosis/consultation page."""
|
||||
|
||||
def list_ai_patient_options(
|
||||
self, *, page_no: int = 1, page_size: int = 20, keyword: str = ""
|
||||
) -> PageResult[dict[str, Any]]:
|
||||
"""Return privacy-safe diagnosis choices for patient AI workflows."""
|
||||
|
||||
def list_prescription_templates(
|
||||
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
|
||||
@@ -206,6 +211,9 @@ class DoctorRepository(Protocol):
|
||||
) -> Iterator[dict[str, Any]]:
|
||||
"""Yield normalized ``start``/``delta``/``done`` assistant events."""
|
||||
|
||||
def generate_ai_prescription(self, diagnosis_id: int) -> dict[str, Any]:
|
||||
"""Generate a server-validated prescription draft for doctor review."""
|
||||
|
||||
def get_diagnosis_ai_analysis(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
@@ -1422,6 +1430,15 @@ class RemoteDoctorRepository:
|
||||
yield {"event": "delta", "text": answer, "fallback": True}
|
||||
yield {**result, "event": "done", "fallback": True}
|
||||
|
||||
def generate_ai_prescription(self, diagnosis_id: int) -> dict[str, Any]:
|
||||
"""Generate a structured draft using the server's full patient context."""
|
||||
|
||||
return self.analyze_diagnosis_ai(
|
||||
diagnosis_id,
|
||||
"请依据患者全部纵向资料生成一份可复核的中医处方草稿。",
|
||||
task="prescription_generate",
|
||||
)
|
||||
|
||||
def get_diagnosis_ai_analysis(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
@@ -1992,10 +2009,10 @@ class RemoteDoctorRepository:
|
||||
|
||||
return self.get_diagnosis_detail(diagnosis_id, readonly=True)
|
||||
|
||||
def list_consultations(
|
||||
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
|
||||
) -> PageResult[Consultation]:
|
||||
"""List diagnosis records using ``tcm.diagnosis/lists``."""
|
||||
def list_consultations(
|
||||
self, *, page_no: int = 1, page_size: int = 20, **filters: Any
|
||||
) -> PageResult[Consultation]:
|
||||
"""List diagnosis records using ``tcm.diagnosis/lists``."""
|
||||
|
||||
request_filters = dict(filters)
|
||||
start_date = str(request_filters.pop("start_date", "") or "").strip()
|
||||
@@ -2017,9 +2034,26 @@ class RemoteDoctorRepository:
|
||||
"tcm.diagnosis/lists",
|
||||
_page_params(page_no, page_size, request_filters),
|
||||
)
|
||||
return PageResult.from_payload(
|
||||
payload, Consultation.from_dict, page_no=page_no, page_size=page_size
|
||||
)
|
||||
return PageResult.from_payload(
|
||||
payload, Consultation.from_dict, page_no=page_no, page_size=page_size
|
||||
)
|
||||
|
||||
def list_ai_patient_options(
|
||||
self, *, page_no: int = 1, page_size: int = 20, keyword: str = ""
|
||||
) -> PageResult[dict[str, Any]]:
|
||||
"""List privacy-safe diagnosis choices through the dedicated AI endpoint."""
|
||||
|
||||
clean_keyword = str(keyword or "").strip()
|
||||
payload = self.client.get(
|
||||
"tcm.diagnosis/aiPatientOptions",
|
||||
_page_params(page_no, page_size, {"keyword": clean_keyword}),
|
||||
)
|
||||
return PageResult.from_payload(
|
||||
payload,
|
||||
_ai_patient_option,
|
||||
page_no=page_no,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
def get_diagnosis_detail(self, diagnosis_id: int, *, readonly: bool = False) -> dict[str, Any]:
|
||||
"""Load a full diagnosis using the appropriate routed endpoint."""
|
||||
@@ -2791,6 +2825,7 @@ def _diagnosis_ai_request(diagnosis_id: int, prompt: str, task: str) -> tuple[st
|
||||
"summary",
|
||||
"tcm_pattern",
|
||||
"prescription_review",
|
||||
"prescription_generate",
|
||||
"medication_review",
|
||||
"exam_review",
|
||||
"complication_risk",
|
||||
@@ -2845,8 +2880,39 @@ def _page_params(page_no: int, page_size: int, filters: Mapping[str, Any]) -> di
|
||||
for key, value in filters.items()
|
||||
if value is not None and not (isinstance(value, (list, tuple, set)) and not value)
|
||||
}
|
||||
result.update({"page_no": page_no, "page_size": page_size})
|
||||
return result
|
||||
result.update({"page_no": page_no, "page_size": page_size})
|
||||
return result
|
||||
|
||||
|
||||
def _ai_patient_option(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Project one AI patient option without exposing a plaintext phone number."""
|
||||
|
||||
diagnosis_id = _to_int(row.get("diagnosis_id", row.get("id")), 0)
|
||||
source_patient_id = _to_int(row.get("source_patient_id", row.get("patient_id")), 0)
|
||||
phone = str(row.get("phone_masked") or "").strip()
|
||||
digits = re.sub(r"\D", "", phone)
|
||||
if len(digits) >= 7 and "*" not in phone:
|
||||
phone = f"{digits[:3]}****{digits[-4:]}"
|
||||
option: dict[str, Any] = {
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"source_patient_id": source_patient_id,
|
||||
"patient_name": str(row.get("patient_name", row.get("name")) or "").strip(),
|
||||
"phone_masked": phone,
|
||||
}
|
||||
for key in (
|
||||
"gender",
|
||||
"gender_desc",
|
||||
"age",
|
||||
"diagnosis_date",
|
||||
"diagnosis_summary",
|
||||
"syndrome_type",
|
||||
"clinical_diagnosis",
|
||||
"last_visit_at",
|
||||
"next_appointment_at",
|
||||
):
|
||||
if key in row:
|
||||
option[key] = row[key]
|
||||
return option
|
||||
|
||||
|
||||
def _require_mapping(value: object, endpoint: str) -> Mapping[str, Any]:
|
||||
|
||||
@@ -61,44 +61,44 @@ from PySide6.QtWidgets import (
|
||||
QWidgetItem,
|
||||
)
|
||||
|
||||
from .theme import crisp_pixmap
|
||||
from .widgets import display_text, first_value, gender_text, get_value
|
||||
|
||||
PRIMARY = QColor("#5265F6")
|
||||
TEXT = QColor("#15224A")
|
||||
SECONDARY = QColor("#7481A3")
|
||||
PLACEHOLDER = QColor("#A4ADC3")
|
||||
_INVALID_INDEX = QModelIndex()
|
||||
_TABLE_COLUMN_WIDTHS = (48, 70, 60, 100, 175, 88, 120, 100, 72, 110, 120, 410)
|
||||
|
||||
|
||||
def _render_signature(value: Any) -> Any:
|
||||
"""Freeze repository DTOs into a stable, order-independent render key."""
|
||||
|
||||
if isinstance(value, Mapping):
|
||||
items = ((str(key), _render_signature(item)) for key, item in value.items())
|
||||
return ("mapping", tuple(sorted(items, key=lambda item: item[0])))
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return ("sequence", tuple(_render_signature(item) for item in value))
|
||||
if isinstance(value, set | frozenset):
|
||||
items = (_render_signature(item) for item in value)
|
||||
return ("set", tuple(sorted(items, key=repr)))
|
||||
try:
|
||||
hash(value)
|
||||
except TypeError:
|
||||
return ("repr", repr(value))
|
||||
return ("value", value)
|
||||
|
||||
|
||||
def _rows_render_signature(rows: Sequence[Any]) -> tuple[Any, ...]:
|
||||
return tuple(_render_signature(row) for row in rows)
|
||||
|
||||
|
||||
def _menu_action_icon(kind: str, *, danger: bool = False) -> QIcon:
|
||||
PRIMARY = QColor("#5265F6")
|
||||
TEXT = QColor("#15224A")
|
||||
SECONDARY = QColor("#7481A3")
|
||||
PLACEHOLDER = QColor("#A4ADC3")
|
||||
_INVALID_INDEX = QModelIndex()
|
||||
_TABLE_COLUMN_WIDTHS = (48, 70, 60, 100, 175, 88, 120, 100, 72, 110, 120, 410)
|
||||
|
||||
|
||||
def _render_signature(value: Any) -> Any:
|
||||
"""Freeze repository DTOs into a stable, order-independent render key."""
|
||||
|
||||
if isinstance(value, Mapping):
|
||||
items = ((str(key), _render_signature(item)) for key, item in value.items())
|
||||
return ("mapping", tuple(sorted(items, key=lambda item: item[0])))
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return ("sequence", tuple(_render_signature(item) for item in value))
|
||||
if isinstance(value, set | frozenset):
|
||||
items = (_render_signature(item) for item in value)
|
||||
return ("set", tuple(sorted(items, key=repr)))
|
||||
try:
|
||||
hash(value)
|
||||
except TypeError:
|
||||
return ("repr", repr(value))
|
||||
return ("value", value)
|
||||
|
||||
|
||||
def _rows_render_signature(rows: Sequence[Any]) -> tuple[Any, ...]:
|
||||
return tuple(_render_signature(row) for row in rows)
|
||||
|
||||
|
||||
def _menu_action_icon(kind: str, *, danger: bool = False) -> QIcon:
|
||||
"""Draw the admin menu glyphs without relying on a symbol font."""
|
||||
|
||||
size = 18
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
pixmap = crisp_pixmap(size)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
color = QColor("#C43E55" if danger else "#667085")
|
||||
@@ -260,32 +260,32 @@ def _appointment_status(record: Any) -> int:
|
||||
return _as_int(status, -1)
|
||||
|
||||
|
||||
def _appointment_active(record: Any) -> bool:
|
||||
return _has_appointment(record) and _appointment_status(record) == 1
|
||||
|
||||
|
||||
def video_call_state(record: Any) -> str:
|
||||
"""Return the server-owned state of the latest video-call session."""
|
||||
|
||||
return str(
|
||||
first_value(
|
||||
record,
|
||||
"video_call_hint.state",
|
||||
"video_hint.state",
|
||||
"video_hint",
|
||||
default="none",
|
||||
)
|
||||
or "none"
|
||||
).strip().lower()
|
||||
|
||||
|
||||
def video_call_is_live(record: Any) -> bool:
|
||||
"""Only a room already opened by the doctor may be joined from this list."""
|
||||
|
||||
return video_call_state(record) == "live"
|
||||
|
||||
|
||||
def _single_cancellable_appointment(record: Any) -> Any | None:
|
||||
def _appointment_active(record: Any) -> bool:
|
||||
return _has_appointment(record) and _appointment_status(record) == 1
|
||||
|
||||
|
||||
def video_call_state(record: Any) -> str:
|
||||
"""Return the server-owned state of the latest video-call session."""
|
||||
|
||||
return str(
|
||||
first_value(
|
||||
record,
|
||||
"video_call_hint.state",
|
||||
"video_hint.state",
|
||||
"video_hint",
|
||||
default="none",
|
||||
)
|
||||
or "none"
|
||||
).strip().lower()
|
||||
|
||||
|
||||
def video_call_is_live(record: Any) -> bool:
|
||||
"""Only a room already opened by the doctor may be joined from this list."""
|
||||
|
||||
return video_call_state(record) == "live"
|
||||
|
||||
|
||||
def _single_cancellable_appointment(record: Any) -> Any | None:
|
||||
"""Return the sole cancellable appointment, never an ambiguous row-level fallback."""
|
||||
|
||||
if not _has_appointment(record) or _appointment_status(record) not in {1, 4}:
|
||||
@@ -338,34 +338,34 @@ def _video_ids_complete(record: Any) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def prescription_action(record: Any, *, force_open: bool = False) -> tuple[str, str]:
|
||||
"""Return the label and immutable click intent for the current appointment."""
|
||||
|
||||
if force_open:
|
||||
return "开方", "open"
|
||||
audit = _as_int(first_value(record, "prescription_audit_status", "audit_status"), -1)
|
||||
voided = _as_int(first_value(record, "prescription_void_status", "void_status"), 0)
|
||||
explicit_current = first_value(record, "current_has_prescription", default=None)
|
||||
if explicit_current is not None:
|
||||
has_current = _as_bool(explicit_current)
|
||||
elif _as_int(first_value(record, "current_prescription_id", default=0)) > 0:
|
||||
has_current = True
|
||||
else:
|
||||
explicit_legacy = first_value(record, "has_prescription", default=None)
|
||||
has_current = (
|
||||
_as_bool(explicit_legacy)
|
||||
if explicit_legacy is not None
|
||||
else audit in {0, 1, 2}
|
||||
)
|
||||
if not has_current:
|
||||
return "开方", "open"
|
||||
if audit == 1 and voided != 1:
|
||||
return "查看处方", "view"
|
||||
return "编辑处方", "edit"
|
||||
|
||||
|
||||
def _prescription_action_label(record: Any, *, force_open: bool = False) -> str:
|
||||
return prescription_action(record, force_open=force_open)[0]
|
||||
def prescription_action(record: Any, *, force_open: bool = False) -> tuple[str, str]:
|
||||
"""Return the label and immutable click intent for the current appointment."""
|
||||
|
||||
if force_open:
|
||||
return "开方", "open"
|
||||
audit = _as_int(first_value(record, "prescription_audit_status", "audit_status"), -1)
|
||||
voided = _as_int(first_value(record, "prescription_void_status", "void_status"), 0)
|
||||
explicit_current = first_value(record, "current_has_prescription", default=None)
|
||||
if explicit_current is not None:
|
||||
has_current = _as_bool(explicit_current)
|
||||
elif _as_int(first_value(record, "current_prescription_id", default=0)) > 0:
|
||||
has_current = True
|
||||
else:
|
||||
explicit_legacy = first_value(record, "has_prescription", default=None)
|
||||
has_current = (
|
||||
_as_bool(explicit_legacy)
|
||||
if explicit_legacy is not None
|
||||
else audit in {0, 1, 2}
|
||||
)
|
||||
if not has_current:
|
||||
return "开方", "open"
|
||||
if audit == 1 and voided != 1:
|
||||
return "查看处方", "view"
|
||||
return "编辑处方", "edit"
|
||||
|
||||
|
||||
def _prescription_action_label(record: Any, *, force_open: bool = False) -> str:
|
||||
return prescription_action(record, force_open=force_open)[0]
|
||||
|
||||
|
||||
class FlowLayout(QLayout):
|
||||
@@ -515,13 +515,13 @@ class DiagnosisTableModel(QAbstractTableModel):
|
||||
"操作",
|
||||
)
|
||||
|
||||
def __init__(self, rows: Iterable[Any] = (), parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.rows: list[Any] = list(rows)
|
||||
self._render_signature = _rows_render_signature(self.rows)
|
||||
self._checked_ids: set[int] = set()
|
||||
self._hover_row = -1
|
||||
self._sort_direction = ""
|
||||
def __init__(self, rows: Iterable[Any] = (), parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.rows: list[Any] = list(rows)
|
||||
self._render_signature = _rows_render_signature(self.rows)
|
||||
self._checked_ids: set[int] = set()
|
||||
self._hover_row = -1
|
||||
self._sort_direction = ""
|
||||
|
||||
def rowCount(self, parent: QModelIndex = _INVALID_INDEX) -> int: # noqa: N802
|
||||
return 0 if parent.isValid() else len(self.rows)
|
||||
@@ -592,24 +592,24 @@ class DiagnosisTableModel(QAbstractTableModel):
|
||||
def record(self, row: int) -> Any:
|
||||
return self.rows[row] if 0 <= row < len(self.rows) else None
|
||||
|
||||
@property
|
||||
def render_signature(self) -> tuple[Any, ...]:
|
||||
return self._render_signature
|
||||
|
||||
def set_rows(self, rows: Iterable[Any]) -> bool:
|
||||
materialized = list(rows)
|
||||
render_signature = _rows_render_signature(materialized)
|
||||
if render_signature == self._render_signature:
|
||||
return False
|
||||
valid_ids = {self.record_id(row) for row in materialized}
|
||||
self.beginResetModel()
|
||||
self.rows = materialized
|
||||
self._render_signature = render_signature
|
||||
self._checked_ids.intersection_update(valid_ids)
|
||||
self._hover_row = -1
|
||||
self.endResetModel()
|
||||
self.selection_changed.emit(len(self._checked_ids))
|
||||
return True
|
||||
@property
|
||||
def render_signature(self) -> tuple[Any, ...]:
|
||||
return self._render_signature
|
||||
|
||||
def set_rows(self, rows: Iterable[Any]) -> bool:
|
||||
materialized = list(rows)
|
||||
render_signature = _rows_render_signature(materialized)
|
||||
if render_signature == self._render_signature:
|
||||
return False
|
||||
valid_ids = {self.record_id(row) for row in materialized}
|
||||
self.beginResetModel()
|
||||
self.rows = materialized
|
||||
self._render_signature = render_signature
|
||||
self._checked_ids.intersection_update(valid_ids)
|
||||
self._hover_row = -1
|
||||
self.endResetModel()
|
||||
self.selection_changed.emit(len(self._checked_ids))
|
||||
return True
|
||||
|
||||
def checked_records(self) -> list[Any]:
|
||||
return [row for row in self.rows if self.record_id(row) in self._checked_ids]
|
||||
@@ -1232,12 +1232,12 @@ class DiagnosisTableView(QTableView):
|
||||
def _current_row_changed(self, _current: QModelIndex, _previous: QModelIndex) -> None:
|
||||
self.itemSelectionChanged.emit()
|
||||
|
||||
def set_rows(self, rows: Iterable[Any]) -> None:
|
||||
if not self._diagnosis_model().set_rows(rows):
|
||||
return
|
||||
self.clearSelection()
|
||||
self.setCurrentIndex(QModelIndex())
|
||||
self.rows_replaced.emit()
|
||||
def set_rows(self, rows: Iterable[Any]) -> None:
|
||||
if not self._diagnosis_model().set_rows(rows):
|
||||
return
|
||||
self.clearSelection()
|
||||
self.setCurrentIndex(QModelIndex())
|
||||
self.rows_replaced.emit()
|
||||
|
||||
def rowCount(self) -> int: # noqa: N802 - compatibility
|
||||
return self._diagnosis_model().rowCount()
|
||||
@@ -1371,6 +1371,9 @@ class DiagnosisTableHost(QFrame):
|
||||
LEFT_WIDTHS = _TABLE_COLUMN_WIDTHS[:10]
|
||||
FIXED_WIDTHS = _TABLE_COLUMN_WIDTHS[10:]
|
||||
|
||||
#: Inline row actions kept before the rest fall back to the 更多 menu.
|
||||
MAX_INLINE_ACTIONS = 2
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -1379,8 +1382,8 @@ class DiagnosisTableHost(QFrame):
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("DiagnosisTableHost")
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.setMinimumHeight(0)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
self.setMinimumHeight(0)
|
||||
self.action_policy = dict(action_policy or {})
|
||||
self.force_open_prescription = False
|
||||
self.model = DiagnosisTableModel(parent=self)
|
||||
@@ -1394,13 +1397,13 @@ class DiagnosisTableHost(QFrame):
|
||||
view.setModel(self.model)
|
||||
view.setSelectionModel(self.selection)
|
||||
view.setItemDelegate(self.delegate)
|
||||
view.setMinimumHeight(0)
|
||||
view.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
view.setMinimumHeight(0)
|
||||
view.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
view.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
|
||||
view.horizontalHeader().setStretchLastSection(False)
|
||||
self.main.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.fixed.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.main.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.fixed.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.main.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.fixed.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.main.setMinimumWidth(0)
|
||||
@@ -1416,7 +1419,7 @@ class DiagnosisTableHost(QFrame):
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self.main, 1)
|
||||
layout.addWidget(self.fixed)
|
||||
layout.setAlignment(self.fixed, Qt.AlignmentFlag.AlignTop)
|
||||
layout.setAlignment(self.fixed, Qt.AlignmentFlag.AlignTop)
|
||||
self.fixed_shadow = _FixedColumnShadow(self)
|
||||
self.fixed_shadow.show()
|
||||
self.empty_label = QLabel("暂无数据", self)
|
||||
@@ -1430,27 +1433,27 @@ class DiagnosisTableHost(QFrame):
|
||||
self.main.hovered_row.connect(self._set_hover_row)
|
||||
self.fixed.hovered_row.connect(self._set_hover_row)
|
||||
self.model.selection_changed.connect(self._checked_changed)
|
||||
self.main.horizontalHeader().sectionClicked.connect(self._header_clicked)
|
||||
self.main.verticalScrollBar().valueChanged.connect(self.fixed.verticalScrollBar().setValue)
|
||||
self.fixed.verticalScrollBar().valueChanged.connect(self.main.verticalScrollBar().setValue)
|
||||
self.main.horizontalScrollBar().rangeChanged.connect(self._schedule_fixed_height_sync)
|
||||
self._fixed_render_signature: tuple[Any, ...] | None = None
|
||||
|
||||
def set_rows(self, rows: Iterable[Any]) -> None:
|
||||
previous_id = DiagnosisTableModel.record_id(self.main.current_data())
|
||||
rows_changed = self.model.set_rows(rows)
|
||||
self.empty_label.setText("暂无数据")
|
||||
self.empty_label.setProperty("stateKind", "empty")
|
||||
self.empty_label.style().unpolish(self.empty_label)
|
||||
self.empty_label.style().polish(self.empty_label)
|
||||
fixed_widgets_changed = self._fixed_widgets_signature() != self._fixed_render_signature
|
||||
if rows_changed or fixed_widgets_changed:
|
||||
self._rows_changed()
|
||||
if rows_changed and previous_id > 0:
|
||||
for index, record in enumerate(self.model.rows):
|
||||
if DiagnosisTableModel.record_id(record) == previous_id:
|
||||
self.main.selectRow(index)
|
||||
break
|
||||
self.main.horizontalHeader().sectionClicked.connect(self._header_clicked)
|
||||
self.main.verticalScrollBar().valueChanged.connect(self.fixed.verticalScrollBar().setValue)
|
||||
self.fixed.verticalScrollBar().valueChanged.connect(self.main.verticalScrollBar().setValue)
|
||||
self.main.horizontalScrollBar().rangeChanged.connect(self._schedule_fixed_height_sync)
|
||||
self._fixed_render_signature: tuple[Any, ...] | None = None
|
||||
|
||||
def set_rows(self, rows: Iterable[Any]) -> None:
|
||||
previous_id = DiagnosisTableModel.record_id(self.main.current_data())
|
||||
rows_changed = self.model.set_rows(rows)
|
||||
self.empty_label.setText("暂无数据")
|
||||
self.empty_label.setProperty("stateKind", "empty")
|
||||
self.empty_label.style().unpolish(self.empty_label)
|
||||
self.empty_label.style().polish(self.empty_label)
|
||||
fixed_widgets_changed = self._fixed_widgets_signature() != self._fixed_render_signature
|
||||
if rows_changed or fixed_widgets_changed:
|
||||
self._rows_changed()
|
||||
if rows_changed and previous_id > 0:
|
||||
for index, record in enumerate(self.model.rows):
|
||||
if DiagnosisTableModel.record_id(record) == previous_id:
|
||||
self.main.selectRow(index)
|
||||
break
|
||||
|
||||
def selected_records(self) -> list[Any]:
|
||||
return self.model.checked_records()
|
||||
@@ -1476,21 +1479,21 @@ class DiagnosisTableHost(QFrame):
|
||||
def set_sort_direction(self, direction: str) -> None:
|
||||
self.model.set_sort_direction(direction)
|
||||
|
||||
def _rows_changed(self) -> None:
|
||||
self._sync_row_heights()
|
||||
self._install_fixed_widgets()
|
||||
self._fixed_render_signature = self._fixed_widgets_signature()
|
||||
self.empty_label.setVisible(self.model.rowCount() == 0)
|
||||
self._position_empty()
|
||||
self._position_fixed_shadow()
|
||||
self._schedule_fixed_height_sync()
|
||||
|
||||
def _fixed_widgets_signature(self) -> tuple[Any, ...]:
|
||||
return (
|
||||
self.model.render_signature,
|
||||
self.force_open_prescription,
|
||||
tuple(sorted(self.action_policy.items())),
|
||||
)
|
||||
def _rows_changed(self) -> None:
|
||||
self._sync_row_heights()
|
||||
self._install_fixed_widgets()
|
||||
self._fixed_render_signature = self._fixed_widgets_signature()
|
||||
self.empty_label.setVisible(self.model.rowCount() == 0)
|
||||
self._position_empty()
|
||||
self._position_fixed_shadow()
|
||||
self._schedule_fixed_height_sync()
|
||||
|
||||
def _fixed_widgets_signature(self) -> tuple[Any, ...]:
|
||||
return (
|
||||
self.model.render_signature,
|
||||
self.force_open_prescription,
|
||||
tuple(sorted(self.action_policy.items())),
|
||||
)
|
||||
|
||||
def _sync_row_heights(self) -> None:
|
||||
for row, record in enumerate(self.model.rows):
|
||||
@@ -1529,32 +1532,32 @@ class DiagnosisTableHost(QFrame):
|
||||
layout.setContentsMargins(3, 2, 3, 2)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
video_capable = self.action_policy.get("video_call", False)
|
||||
call_state = video_call_state(record)
|
||||
if video_capable and _appointment_active(record) and call_state == "live":
|
||||
button = QToolButton(host)
|
||||
button.setText("进入视频问诊")
|
||||
button.setProperty("rowLink", "primary")
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button.setToolTip("医生已发起视频会话,点击进入(将使用摄像头和麦克风)")
|
||||
button.setEnabled(_video_ids_complete(record))
|
||||
if not button.isEnabled():
|
||||
button.setToolTip("患者、诊单或挂号标识不完整,无法进入视频问诊")
|
||||
button.clicked.connect(
|
||||
lambda _checked=False, item=record: self.video_requested.emit(item)
|
||||
)
|
||||
layout.addWidget(button)
|
||||
else:
|
||||
status = "—"
|
||||
if video_capable:
|
||||
if call_state == "pending_room":
|
||||
status = "等待接通"
|
||||
elif _appointment_active(record):
|
||||
status = "暂无通话"
|
||||
else:
|
||||
status = {3: "已完成", 4: "已过号"}.get(
|
||||
_appointment_status(record),
|
||||
"未挂号" if not _has_appointment(record) else "—",
|
||||
)
|
||||
call_state = video_call_state(record)
|
||||
if video_capable and _appointment_active(record) and call_state == "live":
|
||||
button = QToolButton(host)
|
||||
button.setText("进入视频问诊")
|
||||
button.setProperty("rowLink", "primary")
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button.setToolTip("医生已发起视频会话,点击进入(将使用摄像头和麦克风)")
|
||||
button.setEnabled(_video_ids_complete(record))
|
||||
if not button.isEnabled():
|
||||
button.setToolTip("患者、诊单或挂号标识不完整,无法进入视频问诊")
|
||||
button.clicked.connect(
|
||||
lambda _checked=False, item=record: self.video_requested.emit(item)
|
||||
)
|
||||
layout.addWidget(button)
|
||||
else:
|
||||
status = "—"
|
||||
if video_capable:
|
||||
if call_state == "pending_room":
|
||||
status = "等待接通"
|
||||
elif _appointment_active(record):
|
||||
status = "暂无通话"
|
||||
else:
|
||||
status = {3: "已完成", 4: "已过号"}.get(
|
||||
_appointment_status(record),
|
||||
"未挂号" if not _has_appointment(record) else "—",
|
||||
)
|
||||
label = QLabel(display_text(status), host)
|
||||
label.setProperty("fixedMuted", True)
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
@@ -1568,25 +1571,33 @@ class DiagnosisTableHost(QFrame):
|
||||
layout = QHBoxLayout(host)
|
||||
layout.setContentsMargins(4, 2, 4, 2)
|
||||
layout.setSpacing(2)
|
||||
# 该列曾把全部有权限的操作平铺出来,一行最多七个链接,既撑宽操作列,
|
||||
# 又让每一行的宽度都不一样。这里只保留“看诊单 / 开方”两个闭环主操作,
|
||||
# 其余按原顺序降级进本就存在的“更多”菜单。
|
||||
candidates: list[tuple[str, str, str]] = []
|
||||
if self.action_policy.get("view", False):
|
||||
layout.addWidget(self._action_button("查看", "view", record, "primary"))
|
||||
candidates.append(("查看", "view", "primary"))
|
||||
if self.action_policy.get("edit", False):
|
||||
layout.addWidget(self._action_button("诊单", "edit", record, "primary"))
|
||||
candidates.append(("诊单", "edit", "primary"))
|
||||
if self.action_policy.get("prescription", False):
|
||||
layout.addWidget(
|
||||
self._action_button(
|
||||
candidates.append(
|
||||
(
|
||||
_prescription_action_label(record, force_open=self.force_open_prescription),
|
||||
"prescription",
|
||||
record,
|
||||
"primary",
|
||||
)
|
||||
)
|
||||
if self.action_policy.get("ai_consult", False):
|
||||
layout.addWidget(self._action_button("AI 分析", "ai_consult", record, "primary"))
|
||||
if self.action_policy.get("ai_consult", False):
|
||||
candidates.append(("AI 分析", "ai_consult", "primary"))
|
||||
if self.action_policy.get("appointment", False):
|
||||
layout.addWidget(self._action_button("预约", "appointment", record, "success"))
|
||||
candidates.append(("预约", "appointment", "success"))
|
||||
if self.action_policy.get("edit", False):
|
||||
layout.addWidget(self._action_button("补全身份证", "fill_id_card", record, "warning"))
|
||||
candidates.append(("补全身份证", "fill_id_card", "warning"))
|
||||
|
||||
inline_actions = candidates[: self.MAX_INLINE_ACTIONS]
|
||||
demoted_actions = candidates[self.MAX_INLINE_ACTIONS :]
|
||||
for label, action, semantic in inline_actions:
|
||||
layout.addWidget(self._action_button(label, action, record, semantic))
|
||||
more = QToolButton(host)
|
||||
more.setText("更多")
|
||||
more.setArrowType(Qt.ArrowType.DownArrow)
|
||||
@@ -1603,6 +1614,17 @@ class DiagnosisTableHost(QFrame):
|
||||
"QMenu::item:disabled{color:#98A2B3;}"
|
||||
"QMenu::separator{height:1px;background:#D8DEEA;margin:5px 8px;}"
|
||||
)
|
||||
for label, action, _semantic in demoted_actions:
|
||||
_add_menu_action(
|
||||
menu,
|
||||
label,
|
||||
"document",
|
||||
lambda _checked=False, command=action, item=record: self.action_requested.emit(
|
||||
command, item
|
||||
),
|
||||
)
|
||||
if demoted_actions and self.action_policy.get("assign", False):
|
||||
menu.addSeparator()
|
||||
if self.action_policy.get("assign", False):
|
||||
_add_menu_action(
|
||||
menu,
|
||||
@@ -1708,23 +1730,23 @@ class DiagnosisTableHost(QFrame):
|
||||
self.set_sort_direction(direction)
|
||||
self.sort_unserved_requested.emit(direction)
|
||||
|
||||
def _schedule_fixed_height_sync(self, *_range: int) -> None:
|
||||
"""Keep both table viewports equally tall when the main x-scrollbar appears."""
|
||||
|
||||
QTimer.singleShot(0, self._sync_fixed_height)
|
||||
|
||||
def _sync_fixed_height(self) -> None:
|
||||
horizontal = self.main.horizontalScrollBar()
|
||||
reserved = horizontal.sizeHint().height() if horizontal.maximum() > 0 else 0
|
||||
target = max(0, self.height() - reserved)
|
||||
if self.fixed.height() != target:
|
||||
self.fixed.setFixedHeight(target)
|
||||
def _schedule_fixed_height_sync(self, *_range: int) -> None:
|
||||
"""Keep both table viewports equally tall when the main x-scrollbar appears."""
|
||||
|
||||
QTimer.singleShot(0, self._sync_fixed_height)
|
||||
|
||||
def _sync_fixed_height(self) -> None:
|
||||
horizontal = self.main.horizontalScrollBar()
|
||||
reserved = horizontal.sizeHint().height() if horizontal.maximum() > 0 else 0
|
||||
target = max(0, self.height() - reserved)
|
||||
if self.fixed.height() != target:
|
||||
self.fixed.setFixedHeight(target)
|
||||
|
||||
def _position_empty(self) -> None:
|
||||
horizontal = self.main.horizontalScrollBar()
|
||||
reserved = horizontal.sizeHint().height() if horizontal.maximum() > 0 else 0
|
||||
body_height = max(0, self.height() - 39 - reserved)
|
||||
self.empty_label.setGeometry(0, 39, self.width(), body_height)
|
||||
horizontal = self.main.horizontalScrollBar()
|
||||
reserved = horizontal.sizeHint().height() if horizontal.maximum() > 0 else 0
|
||||
body_height = max(0, self.height() - 39 - reserved)
|
||||
self.empty_label.setGeometry(0, 39, self.width(), body_height)
|
||||
self.empty_label.raise_()
|
||||
|
||||
def _position_fixed_shadow(self) -> None:
|
||||
@@ -1739,7 +1761,7 @@ class DiagnosisTableHost(QFrame):
|
||||
|
||||
def resizeEvent(self, event: QResizeEvent) -> None:
|
||||
super().resizeEvent(event)
|
||||
self._sync_fixed_height()
|
||||
self._sync_fixed_height()
|
||||
self._position_empty()
|
||||
self._position_fixed_shadow()
|
||||
|
||||
@@ -1753,16 +1775,16 @@ class DiagnosisPager(QWidget):
|
||||
def __init__(self, page_size: int = 15, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("DiagnosisPager")
|
||||
self.setFixedHeight(42)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.setFixedHeight(42)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.page = 1
|
||||
self.page_size = page_size
|
||||
self.total = 0
|
||||
self._setting = False
|
||||
self._page_buttons: list[QToolButton] = []
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(12, 4, 12, 4)
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(12, 4, 12, 4)
|
||||
layout.setSpacing(6)
|
||||
layout.addStretch(1)
|
||||
self.summary = QLabel("共 0 条")
|
||||
self.summary.setProperty("pagerMuted", True)
|
||||
@@ -2251,8 +2273,8 @@ __all__ = [
|
||||
"DiagnosisLoadingOverlay",
|
||||
"DiagnosisPager",
|
||||
"DiagnosisTableHost",
|
||||
"DiagnosisTableModel",
|
||||
"FlowLayout",
|
||||
"FlowWidget",
|
||||
"prescription_action",
|
||||
]
|
||||
"DiagnosisTableModel",
|
||||
"FlowLayout",
|
||||
"FlowWidget",
|
||||
"prescription_action",
|
||||
]
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
"""Reusable doctor-workstation dialogs."""
|
||||
|
||||
from .ai_consult import AiConsultDialog, can_open_ai_consult, present_ai_consult
|
||||
from .ai_consult_picker import (
|
||||
AiConsultTarget,
|
||||
AiConsultTargetDialog,
|
||||
select_and_present_ai_consult,
|
||||
)
|
||||
from .app_update import AppUpdateDialog, AppUpdateSession
|
||||
from .diagnosis import DiagnosisDialog, OrderDetailDrawer, present_order_detail
|
||||
|
||||
__all__ = [
|
||||
"AiConsultDialog",
|
||||
"AiConsultTarget",
|
||||
"AiConsultTargetDialog",
|
||||
"AppUpdateDialog",
|
||||
"AppUpdateSession",
|
||||
"DiagnosisDialog",
|
||||
"OrderDetailDrawer",
|
||||
"can_open_ai_consult",
|
||||
"present_ai_consult",
|
||||
"present_order_detail",
|
||||
"select_and_present_ai_consult",
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,428 @@
|
||||
"""Permission-scoped diagnosis picker for the global AI assistant entry points."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..theme import mark_business_dialog
|
||||
from ..widgets import (
|
||||
BusyOverlay,
|
||||
EmptyState,
|
||||
MessageBanner,
|
||||
OverlayHost,
|
||||
Pager,
|
||||
SortableTable,
|
||||
TableColumn,
|
||||
first_value,
|
||||
friendly_error,
|
||||
gender_text,
|
||||
get_value,
|
||||
invoke,
|
||||
page_items,
|
||||
page_total,
|
||||
run_async,
|
||||
)
|
||||
from .ai_consult import can_open_ai_consult, present_ai_consult
|
||||
|
||||
|
||||
def _positive_int(value: Any) -> int:
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
return number if number > 0 else 0
|
||||
|
||||
|
||||
def _gender_age(_value: Any, row: Any) -> str:
|
||||
gender = gender_text(get_value(row, "gender", None))
|
||||
age = str(get_value(row, "age", "") or "").strip()
|
||||
parts = [part for part in (gender if gender != "—" else "", f"{age}岁" if age else "") if part]
|
||||
return " · ".join(parts) or "—"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AiConsultTarget:
|
||||
"""A diagnosis-level AI target with a separately verified patient identity."""
|
||||
|
||||
diagnosis_id: int
|
||||
patient_id: int
|
||||
patient_name: str
|
||||
gender: Any = None
|
||||
age: Any = None
|
||||
phone_masked: str = ""
|
||||
diagnosis_date: str = ""
|
||||
diagnosis_summary: str = ""
|
||||
last_visit_at: str = ""
|
||||
next_appointment_at: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Any) -> AiConsultTarget | None:
|
||||
diagnosis_id = _positive_int(first_value(row, "diagnosis_id", "id"))
|
||||
if not diagnosis_id:
|
||||
return None
|
||||
# Patient identity must never fall back to the diagnosis primary key.
|
||||
patient_id = _positive_int(first_value(row, "source_patient_id", "patient_id"))
|
||||
return cls(
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
patient_name=str(first_value(row, "patient_name", "name", default="")).strip(),
|
||||
gender=get_value(row, "gender", None),
|
||||
age=get_value(row, "age", None),
|
||||
# Deliberately do not fall back to a raw phone field.
|
||||
phone_masked=str(get_value(row, "phone_masked", "") or "").strip(),
|
||||
diagnosis_date=str(get_value(row, "diagnosis_date", "") or "").strip(),
|
||||
diagnosis_summary=str(
|
||||
first_value(
|
||||
row,
|
||||
"diagnosis_summary",
|
||||
"clinical_diagnosis",
|
||||
"syndrome_type",
|
||||
default="",
|
||||
)
|
||||
or ""
|
||||
).strip(),
|
||||
last_visit_at=str(get_value(row, "last_visit_at", "") or "").strip(),
|
||||
next_appointment_at=str(
|
||||
get_value(row, "next_appointment_at", "") or ""
|
||||
).strip(),
|
||||
)
|
||||
|
||||
@property
|
||||
def seed(self) -> dict[str, Any]:
|
||||
"""Return only the minimum non-sensitive context needed by the AI dialog."""
|
||||
|
||||
return {
|
||||
"diagnosis_id": self.diagnosis_id,
|
||||
"source_patient_id": self.patient_id,
|
||||
"patient_name": self.patient_name,
|
||||
"gender": self.gender,
|
||||
"age": self.age,
|
||||
"phone_masked": self.phone_masked,
|
||||
"diagnosis_date": self.diagnosis_date,
|
||||
"diagnosis_summary": self.diagnosis_summary,
|
||||
"last_visit_at": self.last_visit_at,
|
||||
"next_appointment_at": self.next_appointment_at,
|
||||
}
|
||||
|
||||
|
||||
class AiConsultTargetDialog(QDialog):
|
||||
"""Search and select one accessible patient diagnosis before opening AI chat."""
|
||||
|
||||
PAGE_SIZE = 20
|
||||
SEARCH_DELAY_MS = 300
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: Any,
|
||||
permissions: Any,
|
||||
parent: QWidget | None = None,
|
||||
*,
|
||||
initial_query: str = "",
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.repository = repository
|
||||
self.permissions = permissions
|
||||
self._page = 1
|
||||
self._generation = 0
|
||||
self._active = True
|
||||
self._loading = False
|
||||
self._selected: AiConsultTarget | None = None
|
||||
self._started = False
|
||||
|
||||
self.setWindowTitle("选择患者资料")
|
||||
self.resize(980, 650)
|
||||
self.setMinimumSize(760, 520)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(24, 22, 24, 20)
|
||||
root.setSpacing(14)
|
||||
|
||||
title = QLabel("选择患者资料", self)
|
||||
title.setProperty("role", "pageTitle")
|
||||
root.addWidget(title)
|
||||
description = QLabel(
|
||||
"从当前账号可查看的全部患者中选择一份诊单,AI 将结合患者资料进行分析。",
|
||||
self,
|
||||
)
|
||||
description.setProperty("role", "muted")
|
||||
description.setWordWrap(True)
|
||||
root.addWidget(description)
|
||||
|
||||
filters = QHBoxLayout()
|
||||
filters.setSpacing(10)
|
||||
self.search_edit = QLineEdit(self)
|
||||
self.search_edit.setObjectName("AiConsultTargetSearch")
|
||||
self.search_edit.setPlaceholderText("搜索患者姓名、掩码手机号或诊单号")
|
||||
self.search_edit.setClearButtonEnabled(True)
|
||||
self.search_edit.setText(str(initial_query or "").strip())
|
||||
self.search_edit.textEdited.connect(self._queue_search)
|
||||
self.search_edit.returnPressed.connect(self.search_now)
|
||||
filters.addWidget(self.search_edit, 1)
|
||||
self.search_button = QPushButton("查询", self)
|
||||
self.search_button.setProperty("variant", "secondary")
|
||||
self.search_button.setAutoDefault(False)
|
||||
self.search_button.clicked.connect(self.search_now)
|
||||
filters.addWidget(self.search_button)
|
||||
root.addLayout(filters)
|
||||
|
||||
self.banner = MessageBanner(parent=self)
|
||||
root.addWidget(self.banner)
|
||||
|
||||
self.body = OverlayHost(self)
|
||||
body_layout = QVBoxLayout(self.body)
|
||||
body_layout.setContentsMargins(0, 0, 0, 0)
|
||||
body_layout.setSpacing(0)
|
||||
self.table = SortableTable(
|
||||
(
|
||||
TableColumn("patient_name", "患者", 100),
|
||||
TableColumn("gender", "性别 / 年龄", 90, _gender_age),
|
||||
TableColumn("phone_masked", "手机号", 110),
|
||||
TableColumn("diagnosis_id", "诊单号", 80),
|
||||
TableColumn("diagnosis_date", "建档日期", 100),
|
||||
TableColumn("last_visit_at", "最近就诊", 120),
|
||||
TableColumn("next_appointment_at", "下次预约", 150),
|
||||
TableColumn("diagnosis_summary", "诊断摘要", 140),
|
||||
),
|
||||
self.body,
|
||||
)
|
||||
# Preserve the server's permission-scoped, recent-first order.
|
||||
self.table.setSortingEnabled(False)
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.table.itemSelectionChanged.connect(self._selection_changed)
|
||||
self.table.itemDoubleClicked.connect(lambda _item: self.accept())
|
||||
body_layout.addWidget(self.table, 1)
|
||||
|
||||
self.empty_state = EmptyState(
|
||||
"未找到患者诊单",
|
||||
"可尝试患者姓名、掩码手机号或诊单号。",
|
||||
"重新加载",
|
||||
self.body,
|
||||
)
|
||||
self.empty_state.action_requested.connect(self.retry)
|
||||
self.empty_state.hide()
|
||||
body_layout.addWidget(self.empty_state, 1)
|
||||
|
||||
self.busy_overlay = BusyOverlay(self.body, "正在加载患者诊单…")
|
||||
self.body.busy_overlay = self.busy_overlay
|
||||
root.addWidget(self.body, 1)
|
||||
|
||||
self.pager = Pager(self.PAGE_SIZE, self)
|
||||
self.pager.page_changed.connect(self._change_page)
|
||||
root.addWidget(self.pager)
|
||||
|
||||
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel, self)
|
||||
cancel = self.buttons.button(QDialogButtonBox.StandardButton.Cancel)
|
||||
cancel.setText("取消")
|
||||
cancel.setAutoDefault(False)
|
||||
self.start_button = self.buttons.addButton(
|
||||
"开始对话", QDialogButtonBox.ButtonRole.AcceptRole
|
||||
)
|
||||
self.start_button.setObjectName("AiConsultTargetStart")
|
||||
self.start_button.setProperty("variant", "primary")
|
||||
self.start_button.setAutoDefault(False)
|
||||
self.start_button.setDefault(False)
|
||||
self.start_button.setEnabled(False)
|
||||
self.start_button.clicked.connect(self.accept)
|
||||
self.buttons.rejected.connect(self.reject)
|
||||
root.addWidget(self.buttons)
|
||||
|
||||
self._search_timer = QTimer(self)
|
||||
self._search_timer.setSingleShot(True)
|
||||
self._search_timer.setInterval(self.SEARCH_DELAY_MS)
|
||||
self._search_timer.timeout.connect(self.search_now)
|
||||
|
||||
mark_business_dialog(self, "AiConsultTargetDialog")
|
||||
QTimer.singleShot(0, self._initial_load)
|
||||
|
||||
def _initial_load(self) -> None:
|
||||
if self._active and not self._started:
|
||||
self._started = True
|
||||
self.load(1)
|
||||
self.search_edit.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def selected_target(self) -> AiConsultTarget | None:
|
||||
return self._selected
|
||||
|
||||
def _queue_search(self, _text: str) -> None:
|
||||
self._invalidate_selection()
|
||||
self._search_timer.start()
|
||||
|
||||
def search_now(self) -> None:
|
||||
self._search_timer.stop()
|
||||
self.load(1)
|
||||
|
||||
def retry(self) -> None:
|
||||
self.load(self._page)
|
||||
|
||||
def _change_page(self, page: int) -> None:
|
||||
self.load(page)
|
||||
|
||||
def load(self, page: int) -> None:
|
||||
if not self._active:
|
||||
return
|
||||
self._page = max(1, int(page))
|
||||
self._generation += 1
|
||||
generation = self._generation
|
||||
page_snapshot = self._page
|
||||
keyword_snapshot = self.search_edit.text().strip()
|
||||
self._invalidate_selection()
|
||||
self._set_loading(True)
|
||||
self.banner.clear()
|
||||
|
||||
run_async(
|
||||
lambda: invoke(
|
||||
self.repository,
|
||||
"list_ai_patient_options",
|
||||
page_no=page_snapshot,
|
||||
page_size=self.PAGE_SIZE,
|
||||
keyword=keyword_snapshot,
|
||||
),
|
||||
on_success=lambda result: self._apply_result(
|
||||
result, generation, page_snapshot, keyword_snapshot
|
||||
),
|
||||
on_error=lambda error: self._apply_error(error, generation),
|
||||
)
|
||||
|
||||
def _apply_result(
|
||||
self,
|
||||
result: Any,
|
||||
generation: int,
|
||||
page_snapshot: int,
|
||||
keyword_snapshot: str,
|
||||
) -> None:
|
||||
if not self._is_current(generation):
|
||||
return
|
||||
# A text edit can arrive before its debounce timer fires. Never paint a
|
||||
# response for the previous text during that interval.
|
||||
if keyword_snapshot != self.search_edit.text().strip():
|
||||
return
|
||||
|
||||
targets = [
|
||||
target
|
||||
for target in (AiConsultTarget.from_row(row) for row in page_items(result))
|
||||
if target is not None
|
||||
]
|
||||
total = max(0, page_total(result, len(targets)))
|
||||
page_count = max(1, (total + self.PAGE_SIZE - 1) // self.PAGE_SIZE)
|
||||
if page_snapshot > page_count:
|
||||
self.load(page_count)
|
||||
return
|
||||
|
||||
self._page = page_snapshot
|
||||
self.table.set_rows(targets)
|
||||
self.table.setSortingEnabled(False)
|
||||
self.table.clearSelection()
|
||||
self.pager.update_state(page_snapshot, total)
|
||||
self.empty_state.setVisible(not targets)
|
||||
self.table.setVisible(bool(targets))
|
||||
self.banner.clear()
|
||||
self._set_loading(False)
|
||||
|
||||
def _apply_error(self, error: Exception, generation: int) -> None:
|
||||
if not self._is_current(generation):
|
||||
return
|
||||
self.table.set_rows(())
|
||||
self.table.setSortingEnabled(False)
|
||||
self.table.clearSelection()
|
||||
self.table.hide()
|
||||
self.empty_state.show()
|
||||
self.pager.update_state(1, 0)
|
||||
self.banner.show_message(
|
||||
f"患者诊单加载失败:{friendly_error(error)}", "danger"
|
||||
)
|
||||
self._set_loading(False)
|
||||
|
||||
def _is_current(self, generation: int) -> bool:
|
||||
return self._active and generation == self._generation
|
||||
|
||||
def _invalidate_selection(self) -> None:
|
||||
self._selected = None
|
||||
self.table.clearSelection()
|
||||
self.start_button.setEnabled(False)
|
||||
|
||||
def _selection_changed(self) -> None:
|
||||
target = self.table.current_data()
|
||||
self.start_button.setEnabled(
|
||||
not self._loading
|
||||
and isinstance(target, AiConsultTarget)
|
||||
and target.diagnosis_id > 0
|
||||
)
|
||||
|
||||
def _set_loading(self, loading: bool) -> None:
|
||||
self._loading = loading
|
||||
self.table.setEnabled(not loading)
|
||||
self.pager.setEnabled(not loading)
|
||||
self.start_button.setEnabled(False if loading else self.table.currentRow() >= 0)
|
||||
self.busy_overlay.setVisible(loading)
|
||||
if loading:
|
||||
self.busy_overlay.raise_()
|
||||
|
||||
def accept(self) -> None:
|
||||
target = self.table.current_data()
|
||||
if not isinstance(target, AiConsultTarget) or target.diagnosis_id <= 0:
|
||||
self.banner.show_message("请选择一条患者诊单。", "warning")
|
||||
self._selected = None
|
||||
return
|
||||
self._selected = target
|
||||
super().accept()
|
||||
|
||||
def done(self, result: int) -> None:
|
||||
self._active = False
|
||||
self._generation += 1
|
||||
self._search_timer.stop()
|
||||
super().done(result)
|
||||
|
||||
|
||||
def select_and_present_ai_consult(
|
||||
repository: Any,
|
||||
permissions: Any,
|
||||
parent: QWidget | None,
|
||||
*,
|
||||
initial_query: str = "",
|
||||
) -> bool:
|
||||
"""Select an accessible diagnosis, then open the existing AI workspace."""
|
||||
|
||||
if not can_open_ai_consult(permissions):
|
||||
return False
|
||||
dialog = AiConsultTargetDialog(
|
||||
repository,
|
||||
permissions,
|
||||
parent,
|
||||
initial_query=initial_query,
|
||||
)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return False
|
||||
target = dialog.selected_target()
|
||||
if target is None or target.diagnosis_id <= 0:
|
||||
return False
|
||||
present_ai_consult(
|
||||
repository,
|
||||
permissions,
|
||||
parent,
|
||||
diagnosis_id=target.diagnosis_id,
|
||||
patient_id=target.patient_id,
|
||||
seed=target.seed,
|
||||
source_title="AI 助手",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AiConsultTarget",
|
||||
"AiConsultTargetDialog",
|
||||
"select_and_present_ai_consult",
|
||||
]
|
||||
@@ -0,0 +1,446 @@
|
||||
"""Desktop update prompt with progress and optional forced install."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, QTimer, Signal, Slot
|
||||
from PySide6.QtGui import QCloseEvent, QShowEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from doctor_workstation.services.app_update import (
|
||||
AppUpdateError,
|
||||
UpdateOffer,
|
||||
apply_extracted_update,
|
||||
current_app_version,
|
||||
discover_payload,
|
||||
download_package,
|
||||
fetch_update_offer,
|
||||
frozen_install_root,
|
||||
is_frozen_install,
|
||||
package_filename,
|
||||
prepare_update_workspace,
|
||||
safe_extract_zip,
|
||||
)
|
||||
from doctor_workstation.ui.widgets import friendly_error, show_toast
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_bytes(value: int) -> str:
|
||||
size = max(0, int(value))
|
||||
if size < 1024:
|
||||
return f"{size} B"
|
||||
if size < 1024 * 1024:
|
||||
return f"{size / 1024:.1f} KB"
|
||||
if size < 1024 * 1024 * 1024:
|
||||
return f"{size / (1024 * 1024):.1f} MB"
|
||||
return f"{size / (1024 * 1024 * 1024):.2f} GB"
|
||||
|
||||
|
||||
class _TaskSignals(QObject):
|
||||
result = Signal(object)
|
||||
error = Signal(object, str)
|
||||
finished = Signal()
|
||||
progress = Signal(int, int)
|
||||
status = Signal(str)
|
||||
|
||||
|
||||
class _Task(QRunnable):
|
||||
def __init__(self, function: Callable[[], Any], signals: _TaskSignals) -> None:
|
||||
super().__init__()
|
||||
self.function = function
|
||||
self.signals = signals
|
||||
|
||||
@Slot()
|
||||
def run(self) -> None:
|
||||
try:
|
||||
result = self.function()
|
||||
except Exception as exc:
|
||||
self.signals.error.emit(exc, traceback.format_exc())
|
||||
else:
|
||||
self.signals.result.emit(result)
|
||||
finally:
|
||||
self.signals.finished.emit()
|
||||
|
||||
|
||||
class AppUpdateDialog(QDialog):
|
||||
"""Offer, download and apply a workstation package."""
|
||||
|
||||
update_accepted = Signal()
|
||||
update_deferred = Signal()
|
||||
download_cancelled = Signal()
|
||||
|
||||
def __init__(self, offer: UpdateOffer, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.offer = offer
|
||||
self._busy = False
|
||||
self.setObjectName("AppUpdateDialog")
|
||||
self.setProperty("businessDialog", True)
|
||||
title = offer.title.strip() or (
|
||||
f"医生工作站 {offer.latest_version}".strip() if offer.latest_version else "软件更新"
|
||||
)
|
||||
self.setWindowTitle(title)
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(520)
|
||||
flags = Qt.WindowType.Dialog | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowSystemMenuHint
|
||||
if not offer.force:
|
||||
flags |= Qt.WindowType.WindowCloseButtonHint
|
||||
self.setWindowFlags(flags)
|
||||
if offer.force:
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(22, 20, 22, 18)
|
||||
root.setSpacing(12)
|
||||
|
||||
self.badge = QLabel("必须更新后才能继续使用" if offer.force else "发现新版本")
|
||||
self.badge.setObjectName("UpdateBadge")
|
||||
self.badge.setStyleSheet(
|
||||
"color:#B45309;background:#FFF5E6;border-radius:8px;padding:4px 10px;font-weight:600;"
|
||||
if offer.force
|
||||
else "color:#4451E2;background:#F0F2FF;border-radius:8px;padding:4px 10px;font-weight:600;"
|
||||
)
|
||||
root.addWidget(self.badge, 0, Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
self.headline = QLabel(title or "医生工作站有新版本")
|
||||
self.headline.setObjectName("UpdateHeadline")
|
||||
self.headline.setStyleSheet("font-size:18px;font-weight:700;")
|
||||
self.headline.setWordWrap(True)
|
||||
root.addWidget(self.headline)
|
||||
|
||||
current = offer.current_version or current_app_version()
|
||||
latest = offer.latest_version or "新版本"
|
||||
self.version_label = QLabel(f"当前版本 {current} → 最新版本 {latest}")
|
||||
self.version_label.setObjectName("UpdateVersionLabel")
|
||||
self.version_label.setStyleSheet("color:#7886AA;")
|
||||
root.addWidget(self.version_label)
|
||||
|
||||
self.notes = QTextEdit()
|
||||
self.notes.setObjectName("UpdateNotes")
|
||||
self.notes.setReadOnly(True)
|
||||
self.notes.setPlainText(offer.notes or "本次更新包含功能与稳定性改进。")
|
||||
self.notes.setMinimumHeight(140)
|
||||
self.notes.setMaximumHeight(220)
|
||||
root.addWidget(self.notes)
|
||||
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setObjectName("UpdateStatus")
|
||||
self.status_label.setWordWrap(True)
|
||||
self.status_label.setStyleSheet("color:#3F4E75;")
|
||||
self.status_label.hide()
|
||||
root.addWidget(self.status_label)
|
||||
|
||||
self.progress = QProgressBar()
|
||||
self.progress.setObjectName("UpdateProgress")
|
||||
self.progress.setRange(0, 100)
|
||||
self.progress.setValue(0)
|
||||
self.progress.setTextVisible(False)
|
||||
self.progress.setFixedHeight(10)
|
||||
self.progress.hide()
|
||||
root.addWidget(self.progress)
|
||||
|
||||
self.progress_text = QLabel("")
|
||||
self.progress_text.setObjectName("UpdateProgressText")
|
||||
self.progress_text.setStyleSheet("color:#7886AA;font-size:12px;")
|
||||
self.progress_text.hide()
|
||||
root.addWidget(self.progress_text)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch(1)
|
||||
self.later_button = QPushButton("稍后提醒")
|
||||
self.later_button.setObjectName("UpdateLaterButton")
|
||||
self.later_button.setProperty("variant", "secondary")
|
||||
self.later_button.clicked.connect(self._defer)
|
||||
self.later_button.setVisible(not offer.force)
|
||||
buttons.addWidget(self.later_button)
|
||||
self.cancel_button = QPushButton("取消下载")
|
||||
self.cancel_button.setObjectName("UpdateCancelButton")
|
||||
self.cancel_button.setProperty("variant", "secondary")
|
||||
self.cancel_button.clicked.connect(self.download_cancelled.emit)
|
||||
self.cancel_button.hide()
|
||||
buttons.addWidget(self.cancel_button)
|
||||
self.update_button = QPushButton("立即更新" if offer.can_install else "暂不可安装")
|
||||
self.update_button.setObjectName("UpdateNowButton")
|
||||
self.update_button.setProperty("variant", "primary")
|
||||
self.update_button.clicked.connect(self._accept_update)
|
||||
buttons.addWidget(self.update_button)
|
||||
root.addLayout(buttons)
|
||||
|
||||
if not offer.can_install:
|
||||
self.status_label.setText("后台尚未配置当前系统对应的安装包,请联系管理员上传后再试。")
|
||||
self.status_label.show()
|
||||
self.update_button.setEnabled(False)
|
||||
|
||||
def set_busy(self, busy: bool) -> None:
|
||||
self._busy = busy
|
||||
self.update_button.setEnabled((not busy) and self.offer.can_install)
|
||||
self.later_button.setEnabled(not busy)
|
||||
self.cancel_button.setVisible(busy and not self.offer.force)
|
||||
self.notes.setEnabled(not busy)
|
||||
|
||||
def show_download_progress(self, received: int, total: int) -> None:
|
||||
self.progress.show()
|
||||
self.progress_text.show()
|
||||
self.status_label.setStyleSheet("color:#3F4E75;")
|
||||
self.status_label.setText("正在下载安装包…")
|
||||
self.status_label.show()
|
||||
if total > 0:
|
||||
self.progress.setRange(0, 100)
|
||||
self.progress.setValue(min(100, int(received * 100 / total)))
|
||||
self.progress_text.setText(f"{_format_bytes(received)} / {_format_bytes(total)}")
|
||||
else:
|
||||
self.progress.setRange(0, 0)
|
||||
self.progress_text.setText(_format_bytes(received))
|
||||
|
||||
def show_status(self, message: str, *, determinate: bool = False) -> None:
|
||||
self.status_label.setStyleSheet("color:#3F4E75;")
|
||||
self.status_label.setText(message)
|
||||
self.status_label.show()
|
||||
self.progress.show()
|
||||
self.progress_text.hide()
|
||||
if determinate:
|
||||
self.progress.setRange(0, 100)
|
||||
self.progress.setValue(100)
|
||||
else:
|
||||
self.progress.setRange(0, 0)
|
||||
|
||||
def show_error(self, message: str) -> None:
|
||||
self._busy = False
|
||||
self.status_label.setText(message)
|
||||
self.status_label.setStyleSheet("color:#F15B67;")
|
||||
self.status_label.show()
|
||||
self.progress.hide()
|
||||
self.progress_text.hide()
|
||||
self.update_button.setText("重试")
|
||||
self.update_button.setEnabled(self.offer.can_install)
|
||||
self.later_button.setEnabled(True)
|
||||
self.cancel_button.hide()
|
||||
self.notes.setEnabled(True)
|
||||
|
||||
def _accept_update(self) -> None:
|
||||
if self._busy or not self.offer.can_install:
|
||||
return
|
||||
self.update_accepted.emit()
|
||||
|
||||
def _defer(self) -> None:
|
||||
if self.offer.force or self._busy:
|
||||
return
|
||||
self.update_deferred.emit()
|
||||
self.reject()
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
|
||||
if self.offer.force or self._busy:
|
||||
event.ignore()
|
||||
return
|
||||
super().closeEvent(event)
|
||||
|
||||
def keyPressEvent(self, event: Any) -> None: # noqa: N802
|
||||
if self.offer.force and event.key() == Qt.Key.Key_Escape:
|
||||
event.ignore()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def showEvent(self, event: QShowEvent) -> None: # noqa: N802
|
||||
super().showEvent(event)
|
||||
self.setProperty("businessDialog", True)
|
||||
|
||||
|
||||
class AppUpdateSession(QObject):
|
||||
"""Owns update checks for the login and authenticated shell."""
|
||||
|
||||
def __init__(self, host: Any) -> None:
|
||||
super().__init__(host)
|
||||
self.host = host
|
||||
self.dialog: AppUpdateDialog | None = None
|
||||
self._generation = 0
|
||||
self._dismissed: set[str] = set()
|
||||
self._cancel = False
|
||||
self._signals: _TaskSignals | None = None
|
||||
|
||||
def schedule(self, *, interactive: bool = False, delay_ms: int = 700) -> None:
|
||||
self._generation += 1
|
||||
generation = self._generation
|
||||
QTimer.singleShot(
|
||||
max(0, delay_ms),
|
||||
lambda: self.check(interactive=interactive, generation=generation),
|
||||
)
|
||||
|
||||
def check(self, *, interactive: bool = False, generation: int | None = None) -> None:
|
||||
if generation is None:
|
||||
self._generation += 1
|
||||
generation = self._generation
|
||||
if generation != self._generation:
|
||||
return
|
||||
if not self._should_check() and not interactive:
|
||||
return
|
||||
client = self._client()
|
||||
parent = self._parent_window()
|
||||
if client is None:
|
||||
if interactive and parent is not None:
|
||||
show_toast(parent, "请先填写服务器地址后再检查更新。", "warning")
|
||||
return
|
||||
signals = _TaskSignals()
|
||||
self._signals = signals
|
||||
worker = _Task(
|
||||
lambda: fetch_update_offer(client, current_version=current_app_version()),
|
||||
signals,
|
||||
)
|
||||
signals.result.connect(
|
||||
lambda offer: self._on_offer(offer, interactive=interactive, generation=generation)
|
||||
)
|
||||
signals.error.connect(
|
||||
lambda error, _tb: self._on_check_error(
|
||||
error, interactive=interactive, generation=generation
|
||||
)
|
||||
)
|
||||
QThreadPool.globalInstance().start(worker)
|
||||
|
||||
def _should_check(self) -> bool:
|
||||
if getattr(self.host, "_shutting_down", False):
|
||||
return False
|
||||
if os.getenv("DOCTOR_SMOKE_TEST") == "1":
|
||||
return False
|
||||
if "--smoke-test" in sys.argv:
|
||||
return False
|
||||
config = getattr(self.host, "config", None)
|
||||
return bool(getattr(config, "api_base_url", ""))
|
||||
|
||||
def _client(self) -> Any:
|
||||
repository = getattr(self.host, "remote_repository", None)
|
||||
return getattr(repository, "client", None) if repository is not None else None
|
||||
|
||||
def _parent_window(self) -> QWidget | None:
|
||||
shell = getattr(self.host, "shell_window", None)
|
||||
if shell is not None:
|
||||
return shell
|
||||
return getattr(self.host, "login_window", None)
|
||||
|
||||
def _on_check_error(self, error: Exception, *, interactive: bool, generation: int) -> None:
|
||||
if generation != self._generation:
|
||||
return
|
||||
LOGGER.info("desktop update check failed: %s", error)
|
||||
parent = self._parent_window()
|
||||
if interactive and parent is not None:
|
||||
show_toast(parent, f"检查更新失败:{friendly_error(error)}", "warning", 4200)
|
||||
|
||||
def _on_offer(self, offer: object, *, interactive: bool, generation: int) -> None:
|
||||
if generation != self._generation or not isinstance(offer, UpdateOffer):
|
||||
return
|
||||
parent = self._parent_window()
|
||||
if not offer.has_update:
|
||||
if interactive and parent is not None:
|
||||
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)
|
||||
self._present(offer)
|
||||
|
||||
def _present(self, offer: UpdateOffer) -> None:
|
||||
parent = self._parent_window()
|
||||
if self.dialog is not None:
|
||||
self.dialog.close()
|
||||
self.dialog.deleteLater()
|
||||
dialog = AppUpdateDialog(offer, parent)
|
||||
dialog.update_accepted.connect(lambda: self._start_install(dialog, offer))
|
||||
dialog.update_deferred.connect(lambda: self._dismissed.add(offer.latest_version))
|
||||
dialog.download_cancelled.connect(lambda: self._request_cancel(dialog))
|
||||
self.dialog = dialog
|
||||
dialog.show()
|
||||
dialog.raise_()
|
||||
dialog.activateWindow()
|
||||
|
||||
def _request_cancel(self, dialog: AppUpdateDialog) -> None:
|
||||
if dialog.offer.force:
|
||||
return
|
||||
self._cancel = True
|
||||
dialog.show_status("正在取消…")
|
||||
|
||||
def _start_install(self, dialog: AppUpdateDialog, offer: UpdateOffer) -> None:
|
||||
package = offer.package
|
||||
if package is None:
|
||||
return
|
||||
if not is_frozen_install():
|
||||
dialog.show_error("当前为源码运行,无法自动安装。请使用发布 ZIP 安装后再更新。")
|
||||
return
|
||||
if frozen_install_root() is None:
|
||||
dialog.show_error("无法确定当前安装目录,已取消自动更新。")
|
||||
return
|
||||
config = getattr(self.host, "config", None)
|
||||
if config is None:
|
||||
dialog.show_error("无法读取本地配置目录,已取消自动更新。")
|
||||
return
|
||||
config_dir = Path(config.config_dir)
|
||||
verify = bool(getattr(config, "verify_ssl", True))
|
||||
self._cancel = False
|
||||
dialog.set_busy(True)
|
||||
dialog.show_download_progress(0, package.size)
|
||||
signals = _TaskSignals()
|
||||
self._signals = signals
|
||||
|
||||
def job() -> Path:
|
||||
workspace = prepare_update_workspace(config_dir, offer.latest_version)
|
||||
archive = workspace / package_filename(package, offer.latest_version)
|
||||
download_package(
|
||||
package.url,
|
||||
archive,
|
||||
sha256=package.sha256,
|
||||
verify=verify,
|
||||
expected_size=package.size,
|
||||
progress=lambda received, total: signals.progress.emit(received, total),
|
||||
cancelled=lambda: self._cancel,
|
||||
)
|
||||
signals.status.emit("正在校验并解压安装包…")
|
||||
extracted = workspace / "payload"
|
||||
safe_extract_zip(archive, extracted)
|
||||
return 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.error.connect(lambda error, _tb: self._install_failed(dialog, error))
|
||||
QThreadPool.globalInstance().start(worker)
|
||||
|
||||
def _install_failed(self, dialog: AppUpdateDialog, error: Exception) -> None:
|
||||
LOGGER.exception("desktop update failed")
|
||||
if self._cancel and not dialog.offer.force:
|
||||
dialog.show_error("已取消下载。")
|
||||
self._cancel = False
|
||||
return
|
||||
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("安装包解压结果无效。")
|
||||
return
|
||||
dialog.show_status("即将关闭并完成安装…", determinate=True)
|
||||
try:
|
||||
apply_extracted_update(payload)
|
||||
except AppUpdateError as error:
|
||||
dialog.show_error(str(error))
|
||||
return
|
||||
application = QApplication.instance()
|
||||
if application is not None:
|
||||
QTimer.singleShot(300, application.quit)
|
||||
@@ -93,6 +93,7 @@ from ..widgets import (
|
||||
MessageBanner,
|
||||
display_text,
|
||||
first_value,
|
||||
format_record_time,
|
||||
friendly_error,
|
||||
get_value,
|
||||
has_permission,
|
||||
@@ -3692,6 +3693,11 @@ class PrescriptionEditorDialog(QDialog):
|
||||
hidden_keys = (
|
||||
"id",
|
||||
"diagnosis_id",
|
||||
"appointment_id",
|
||||
"patient_id",
|
||||
"phone",
|
||||
"case_record",
|
||||
"prescription_name",
|
||||
"creator_id",
|
||||
"is_system_auto",
|
||||
"is_shared",
|
||||
@@ -4233,20 +4239,9 @@ def render_prescription_html(
|
||||
def date_text() -> str:
|
||||
raw = source.get("create_time") or source.get("update_time")
|
||||
if raw not in (None, ""):
|
||||
raw_text = str(raw).strip()
|
||||
if re.fullmatch(r"\d{10,13}(?:\.\d+)?", raw_text):
|
||||
stamp = float(raw_text)
|
||||
if stamp >= 10_000_000_000:
|
||||
stamp /= 1000
|
||||
try:
|
||||
return datetime.fromtimestamp(stamp).strftime("%Y-%m-%d %H:%M")
|
||||
except (OSError, OverflowError, ValueError):
|
||||
pass
|
||||
normalized = raw_text.replace("T", " ").replace("Z", "")
|
||||
if len(normalized) >= 16:
|
||||
return normalized[:16]
|
||||
if normalized:
|
||||
return normalized
|
||||
formatted = format_record_time(raw, default="")
|
||||
if formatted:
|
||||
return formatted
|
||||
return display_text(source.get("prescription_date"))
|
||||
|
||||
def usage_text(values: Mapping[str, Any], *, fallback: Mapping[str, Any]) -> str:
|
||||
|
||||
@@ -40,6 +40,9 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from doctor_workstation import __version__
|
||||
|
||||
from .theme import crisp_pixmap
|
||||
from .widgets import BusyOverlay, MessageBanner, friendly_error, invoke, run_async
|
||||
|
||||
|
||||
@@ -900,6 +903,12 @@ class LoginWindow(QMainWindow):
|
||||
footnote.setFixedHeight(40)
|
||||
footnote_row.addWidget(footnote, 1)
|
||||
card_layout.addLayout(footnote_row)
|
||||
self.version_label = QLabel(f"当前版本 {__version__}")
|
||||
self.version_label.setObjectName("LoginVersionLabel")
|
||||
self.version_label.setProperty("role", "muted")
|
||||
self.version_label.setStyleSheet("color:#8B98B5; font-size:13px;")
|
||||
self.version_label.setContentsMargins(0, 8, 0, 0)
|
||||
card_layout.addWidget(self.version_label, 0, Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
outer.addWidget(
|
||||
self.card, 0, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop
|
||||
@@ -910,8 +919,7 @@ class LoginWindow(QMainWindow):
|
||||
|
||||
@staticmethod
|
||||
def _window_icon() -> QIcon:
|
||||
pixmap = QPixmap(64, 64)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
pixmap = crisp_pixmap(64)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
_BrandPanel._draw_mark(painter, QRectF(1, 1, 62, 62))
|
||||
@@ -920,8 +928,7 @@ class LoginWindow(QMainWindow):
|
||||
|
||||
@staticmethod
|
||||
def _account_icon() -> QIcon:
|
||||
pixmap = QPixmap(24, 24)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
pixmap = crisp_pixmap(24)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(_round_pen("#8292B6", 2))
|
||||
@@ -933,8 +940,7 @@ class LoginWindow(QMainWindow):
|
||||
|
||||
@staticmethod
|
||||
def _lock_icon() -> QIcon:
|
||||
pixmap = QPixmap(20, 20)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
pixmap = crisp_pixmap(20)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(_round_pen("#8FA0C4", 1.6))
|
||||
|
||||
@@ -92,9 +92,11 @@ _SEMANTIC_COLORS = {
|
||||
}
|
||||
|
||||
APPOINTMENTS_LIGHT_QSS = """
|
||||
/* 页头此前被压到只剩面包屑(26px),与其余列表页的“面包屑+标题+副标题”
|
||||
骨架不一致。这里给它与 PageHeader 自然高度相符的空间。 */
|
||||
#AppointmentsPage QWidget#PageHeader {
|
||||
min-height: 26px;
|
||||
max-height: 26px;
|
||||
min-height: 62px;
|
||||
max-height: 62px;
|
||||
}
|
||||
#AppointmentsPage QFrame#AppointmentFilterPanel {
|
||||
min-height: 80px;
|
||||
@@ -128,17 +130,13 @@ APPOINTMENTS_LIGHT_QSS = """
|
||||
border-color: #5265F6;
|
||||
font-weight: 600;
|
||||
}
|
||||
#AppointmentsPage QPushButton[appointmentStatKind="pending"] {
|
||||
color: #5265F6;
|
||||
background-color: #F6F4FF;
|
||||
}
|
||||
#AppointmentsPage QPushButton[appointmentStatKind="success"] {
|
||||
color: #159C79;
|
||||
background-color: #F1FAF7;
|
||||
}
|
||||
#AppointmentsPage QPushButton[appointmentStatKind="warning"] {
|
||||
/* 这一排全是筛选片,此前按业务语义分别染成紫/绿/琥珀,一行出现四种底色,
|
||||
而且颜色和“是否选中”这一真正需要区分的状态互相打架。筛选片一律保持中性,
|
||||
只有“待分配医助”在确有待办时才提示为琥珀色。 */
|
||||
#AppointmentsPage QPushButton[appointmentStatKind="warning"][hasPending="true"] {
|
||||
color: #C17A16;
|
||||
background-color: #FFF8ED;
|
||||
border-color: #F0DCB6;
|
||||
}
|
||||
#AppointmentsPage QLineEdit#AppointmentPatientSearch {
|
||||
min-height: 30px;
|
||||
@@ -671,9 +669,9 @@ class AppointmentsPage(QWidget):
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(18, 3, 6, 8)
|
||||
root.setSpacing(4)
|
||||
self.header = PageHeader("问诊列表")
|
||||
self.header.title_label.hide()
|
||||
self.header.subtitle_label.hide()
|
||||
# 其余列表页都有“面包屑 + 标题 + 副标题”,这一页此前把标题隐藏了,
|
||||
# 导致同一套列表页有两种页头形态。保留页头以对齐全局页面骨架。
|
||||
self.header = PageHeader("挂号列表", "管理当日与近期挂号,确认到号、指派医助并进入接诊。")
|
||||
root.addWidget(self.header)
|
||||
self.filter_panel = self._build_filter_panel()
|
||||
root.addWidget(self.filter_panel)
|
||||
@@ -734,13 +732,11 @@ class AppointmentsPage(QWidget):
|
||||
|
||||
self.pending_stat_button = QPushButton("待预约 0")
|
||||
self.pending_stat_button.setProperty("appointmentStat", True)
|
||||
self.pending_stat_button.setProperty("appointmentStatKind", "pending")
|
||||
self.pending_stat_button.setMinimumWidth(0)
|
||||
self.pending_stat_button.clicked.connect(lambda: self._set_status_from_stat(1))
|
||||
date_row.addWidget(self.pending_stat_button, 1)
|
||||
self.completed_stat_button = QPushButton("已完成 0")
|
||||
self.completed_stat_button.setProperty("appointmentStat", True)
|
||||
self.completed_stat_button.setProperty("appointmentStatKind", "success")
|
||||
self.completed_stat_button.setMinimumWidth(0)
|
||||
self.completed_stat_button.clicked.connect(lambda: self._set_status_from_stat(3))
|
||||
date_row.addWidget(self.completed_stat_button, 1)
|
||||
@@ -1328,7 +1324,13 @@ class AppointmentsPage(QWidget):
|
||||
if _as_int(first_value(row, "assistant_id", default=0)) <= 0
|
||||
and not str(first_value(row, "assistant_name", default="") or "").strip()
|
||||
)
|
||||
self.unassigned_stat_button.setText(f"待分配医助 {_as_int(unassigned)}")
|
||||
unassigned_count = _as_int(unassigned)
|
||||
self.unassigned_stat_button.setText(f"待分配医助 {unassigned_count}")
|
||||
# 只有真的有待办时才点亮,避免筛选栏常态就是一排彩色。
|
||||
self.unassigned_stat_button.setProperty("hasPending", unassigned_count > 0)
|
||||
style = self.unassigned_stat_button.style()
|
||||
style.unpolish(self.unassigned_stat_button)
|
||||
style.polish(self.unassigned_stat_button)
|
||||
|
||||
def _install_table_selectors(self) -> None:
|
||||
for row_index in range(self.table.rowCount()):
|
||||
|
||||
@@ -40,12 +40,14 @@ from ..appointment_drawer import AppointmentDrawer
|
||||
from ..dialogs import DiagnosisDialog, present_ai_consult, present_order_detail
|
||||
from ..dialogs.ai_consult import can_open_ai_consult
|
||||
from ..dialogs.prescription import PrescriptionOrderListDialog
|
||||
from ..theme import mark_business_dialog
|
||||
from ..theme import crisp_pixmap, mark_business_dialog
|
||||
from ..widgets import (
|
||||
EmptyState,
|
||||
MessageBanner,
|
||||
PageHeader,
|
||||
Pager,
|
||||
RowAction,
|
||||
RowActions,
|
||||
SortableTable,
|
||||
StatusBadge,
|
||||
TableColumn,
|
||||
@@ -302,8 +304,7 @@ PATIENTS_LIGHT_QSS = """
|
||||
def _summary_calendar_icon() -> QIcon:
|
||||
"""Paint the compact calendar tile from the reference without a font glyph."""
|
||||
|
||||
pixmap = QPixmap(34, 34)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
pixmap = crisp_pixmap(34)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
@@ -1511,39 +1512,41 @@ class PatientListWorkspace(QWidget):
|
||||
self.table = SortableTable(
|
||||
[
|
||||
TableColumn("_selected", "", 40, alignment=Qt.AlignmentFlag.AlignCenter),
|
||||
# 手机号另有独立列,姓名列不再重复第二行,单行即可放下完整姓名。
|
||||
TableColumn(
|
||||
"patient_name",
|
||||
"患者信息",
|
||||
180,
|
||||
168,
|
||||
lambda _value, row: (
|
||||
f"{display_text(first_value(row, 'patient_name', 'name'))} · "
|
||||
f"{gender_text(first_value(row, 'gender_desc', 'gender'))} · "
|
||||
f"{display_text(first_value(row, 'age'))}岁\n"
|
||||
f"{display_text(first_value(row, 'phone_masked', 'phone'), '—')}"
|
||||
f"{display_text(first_value(row, 'age'))}岁"
|
||||
),
|
||||
),
|
||||
TableColumn("assistant_name", "归属助理", 84),
|
||||
TableColumn("assistant_name", "归属助理", 90),
|
||||
TableColumn(
|
||||
"appointment_doctor_name",
|
||||
"预约医生",
|
||||
108,
|
||||
150,
|
||||
lambda _value, row: (
|
||||
f"{display_text(first_value(row, 'appointment_doctor_name'), '未预约')} / "
|
||||
f"{_patient_status(row)[0]}"
|
||||
),
|
||||
),
|
||||
TableColumn("appointment_time_text", "预约时间", 116),
|
||||
TableColumn("appointment_time_text", "预约时间", 150),
|
||||
TableColumn(
|
||||
"revisit_count",
|
||||
"复诊",
|
||||
72,
|
||||
64,
|
||||
lambda value, _row: f"{display_text(value, '0')} 次",
|
||||
Qt.AlignmentFlag.AlignCenter,
|
||||
),
|
||||
TableColumn("confirmation_text", "确认信息", 88),
|
||||
TableColumn("diagnosis_date_text", "诊单日期", 96),
|
||||
TableColumn("diagnosis_date_text", "诊单日期", 104),
|
||||
TableColumn("phone_masked", "手机", 116),
|
||||
TableColumn("_actions", "操作", 500),
|
||||
# 行操作收敛为“2 个常用 + 更多”后,这一列不再需要 500px 的宽度,
|
||||
# 让出的空间还给此前被省略号截断的数据列。
|
||||
TableColumn("_actions", "操作", 210),
|
||||
]
|
||||
)
|
||||
self.table.setObjectName("PatientTable")
|
||||
@@ -1592,25 +1595,16 @@ class PatientListWorkspace(QWidget):
|
||||
selector_item = self.table.item(row_index, 0)
|
||||
if selector_item is not None:
|
||||
selector_item.setText("")
|
||||
host = QWidget(self.table)
|
||||
layout = QHBoxLayout(host)
|
||||
layout.setContentsMargins(5, 3, 5, 3)
|
||||
layout.setSpacing(4)
|
||||
row_actions: list[RowAction] = []
|
||||
|
||||
def add_action(
|
||||
label: str,
|
||||
callback: Any,
|
||||
*,
|
||||
danger: bool = False,
|
||||
_host: QWidget = host,
|
||||
_layout: QHBoxLayout = layout,
|
||||
_sink: list[RowAction] = row_actions,
|
||||
) -> None:
|
||||
button = QPushButton(label, _host)
|
||||
button.setProperty("rowAction", True)
|
||||
if danger:
|
||||
button.setProperty("variant", "danger")
|
||||
button.clicked.connect(callback)
|
||||
_layout.addWidget(button)
|
||||
_sink.append(RowAction(label, callback, danger=danger))
|
||||
|
||||
if editable or readable:
|
||||
add_action(
|
||||
@@ -1652,12 +1646,12 @@ class PatientListWorkspace(QWidget):
|
||||
lambda _checked=False, value=row: self.cancel_requested.emit(value),
|
||||
danger=True,
|
||||
)
|
||||
layout.addStretch(1)
|
||||
host = RowActions(row_actions, self.table)
|
||||
self.table.setCellWidget(row_index, action_column, host)
|
||||
action_item = self.table.item(row_index, action_column)
|
||||
if action_item is not None:
|
||||
action_item.setText("")
|
||||
self.table.setRowHeight(row_index, 40)
|
||||
self.table.setRowHeight(row_index, 36)
|
||||
|
||||
def _build_actions(self) -> QHBoxLayout:
|
||||
layout = QHBoxLayout()
|
||||
|
||||
@@ -34,6 +34,7 @@ from ..widgets import (
|
||||
TableColumn,
|
||||
display_text,
|
||||
first_value,
|
||||
format_record_time,
|
||||
friendly_error,
|
||||
get_value,
|
||||
has_permission,
|
||||
@@ -214,6 +215,11 @@ def _efficacy_text(_value: Any, row: Any) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _create_time_cell(value: Any, _row: Any) -> str:
|
||||
raw = first_value(_row, "create_time_text", "create_time", default=value)
|
||||
return format_record_time(raw)
|
||||
|
||||
|
||||
def _metric_card(title: str, kind: str = "accent") -> MetricCard:
|
||||
card = MetricCard(title, "0", kind=kind, glyph="")
|
||||
card.setFixedHeight(64)
|
||||
@@ -427,7 +433,7 @@ class PrescriptionLibraryPage(QWidget):
|
||||
TableColumn("efficacy", "功效主治", 170, _efficacy_text),
|
||||
TableColumn("is_public", "公开范围", 135, _visibility_text),
|
||||
TableColumn("creator_name", "创建人", 100),
|
||||
TableColumn("create_time", "创建时间", 178),
|
||||
TableColumn("create_time", "创建时间", 178, _create_time_cell),
|
||||
TableColumn("__actions__", "操作", 160, lambda _value, _row: ""),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -36,6 +36,7 @@ from ..dialogs.prescription import (
|
||||
PrescriptionOrderDialog,
|
||||
PrescriptionOrderListDialog,
|
||||
)
|
||||
from ..theme import crisp_pixmap
|
||||
from ..widgets import (
|
||||
BusinessPager,
|
||||
EmptyState,
|
||||
@@ -45,6 +46,7 @@ from ..widgets import (
|
||||
TableColumn,
|
||||
display_text,
|
||||
first_value,
|
||||
format_record_time,
|
||||
friendly_error,
|
||||
get_value,
|
||||
has_permission,
|
||||
@@ -105,6 +107,10 @@ PRESCRIPTIONS_PAGE_QSS = """
|
||||
padding: 0; border-radius: 7px; background: #FFFFFF;
|
||||
border: 1px solid #DCE3F5;
|
||||
}
|
||||
#PrescriptionsPage QPushButton[rowAction="true"][labeled="true"] {
|
||||
min-width: 64px; max-width: 64px; padding: 0 9px;
|
||||
color: #315CF4; font-weight: 600;
|
||||
}
|
||||
#PrescriptionsPage QPushButton[rowAction="true"]:hover {
|
||||
background: #F3F5FF; border-color: #AAB7FF;
|
||||
}
|
||||
@@ -157,8 +163,7 @@ PRESCRIPTIONS_PAGE_QSS = """
|
||||
def _painted_icon(kind: str, color: str = "#5265F6", size: int = 16) -> QIcon:
|
||||
"""Return a crisp page-local icon without relying on emoji or icon fonts."""
|
||||
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
pixmap = crisp_pixmap(size)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
pen = QPen(QColor(color), max(1.25, size / 11.5))
|
||||
@@ -265,13 +270,20 @@ def _row_action_button(
|
||||
*,
|
||||
danger: bool = False,
|
||||
enabled: bool = True,
|
||||
label: str = "",
|
||||
mutation: bool = False,
|
||||
) -> QPushButton:
|
||||
button = QPushButton(parent)
|
||||
button.setProperty("rowAction", True)
|
||||
button.setProperty("danger", danger)
|
||||
button.setProperty("labeled", bool(label))
|
||||
button.setProperty("mutationAction", mutation)
|
||||
button.setProperty("rowAllowed", enabled)
|
||||
button.setText(label)
|
||||
button.setAccessibleName(tooltip)
|
||||
button.setToolTip(tooltip)
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button.setFixedSize(64 if label else 28, 28)
|
||||
button.setIcon(_painted_icon(kind, "#F34E64" if danger else "#4965F5", 15))
|
||||
button.setIconSize(QSize(15, 15))
|
||||
button.setEnabled(enabled)
|
||||
@@ -427,6 +439,11 @@ def _doctor_cell(_value: Any, row: Any) -> str:
|
||||
return str(first_value(row, "doctor_name", "creator_name", default="—"))
|
||||
|
||||
|
||||
def _create_time_cell(value: Any, _row: Any) -> str:
|
||||
raw = first_value(_row, "create_time_text", "create_time", default=value)
|
||||
return format_record_time(raw)
|
||||
|
||||
|
||||
class DoctorMultiSelect(QWidget):
|
||||
"""Compact checkable doctor selector fed by list rows/extend data."""
|
||||
|
||||
@@ -682,6 +699,7 @@ class PrescriptionsPage(QWidget):
|
||||
[
|
||||
TableColumn("__selected__", "", 46, lambda _value, _row: ""),
|
||||
TableColumn("sn", "处方编号", 174, _sn_cell),
|
||||
TableColumn("__actions__", "操作", 150, lambda _value, _row: ""),
|
||||
TableColumn("prescription_type", "处方类型", 96),
|
||||
TableColumn("is_system_auto", "来源", 88, _source_cell),
|
||||
TableColumn("patient_name", "患者信息", 190, _patient_cell),
|
||||
@@ -689,8 +707,7 @@ class PrescriptionsPage(QWidget):
|
||||
TableColumn("void_status", "作废", 72, _void_cell),
|
||||
TableColumn("doctor_name", "医生信息", 180, _doctor_cell),
|
||||
TableColumn("assistant_name", "医助", 125),
|
||||
TableColumn("create_time", "创建时间", 180),
|
||||
TableColumn("__actions__", "操作", 150, lambda _value, _row: ""),
|
||||
TableColumn("create_time", "创建时间", 180, _create_time_cell),
|
||||
]
|
||||
)
|
||||
self.table.verticalHeader().setDefaultSectionSize(36)
|
||||
@@ -840,7 +857,7 @@ class PrescriptionsPage(QWidget):
|
||||
return
|
||||
rows = page_items(result)
|
||||
self.table.set_rows(rows)
|
||||
self._decorate_rows(rows)
|
||||
self._decorate_rows()
|
||||
total = page_total(result, len(rows))
|
||||
self.pager.update_state(requested_page, total)
|
||||
self.count_badge.setText(f"共 {total} 条")
|
||||
@@ -864,20 +881,27 @@ class PrescriptionsPage(QWidget):
|
||||
self.table.selectRow(0)
|
||||
self._selection_changed()
|
||||
|
||||
def _decorate_rows(self, rows: list[Any]) -> None:
|
||||
def _decorate_rows(self) -> None:
|
||||
"""Apply the reference table's tags, checkbox, avatar, and row actions."""
|
||||
|
||||
for row_index, row in enumerate(rows):
|
||||
for row_index in range(self.table.rowCount()):
|
||||
selector = self.table.item(row_index, 0)
|
||||
if selector is not None:
|
||||
selector.setFlags(
|
||||
selector.flags()
|
||||
| Qt.ItemFlag.ItemIsUserCheckable
|
||||
| Qt.ItemFlag.ItemIsEnabled
|
||||
| Qt.ItemFlag.ItemIsSelectable
|
||||
)
|
||||
selector.setCheckState(Qt.CheckState.Unchecked)
|
||||
selector.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
if selector is None:
|
||||
continue
|
||||
# Read the row back from the visual table. SortableTable may have
|
||||
# already reordered rows, so decorating the original result list
|
||||
# can bind an action button to the wrong visible prescription.
|
||||
row = selector.data(Qt.ItemDataRole.UserRole)
|
||||
if row is None:
|
||||
continue
|
||||
selector.setFlags(
|
||||
selector.flags()
|
||||
| Qt.ItemFlag.ItemIsUserCheckable
|
||||
| Qt.ItemFlag.ItemIsEnabled
|
||||
| Qt.ItemFlag.ItemIsSelectable
|
||||
)
|
||||
selector.setCheckState(Qt.CheckState.Unchecked)
|
||||
selector.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
sn_item = self.table.item(row_index, 1)
|
||||
if sn_item is not None:
|
||||
@@ -894,7 +918,7 @@ class PrescriptionsPage(QWidget):
|
||||
)
|
||||
self.table.setCellWidget(
|
||||
row_index,
|
||||
2,
|
||||
3,
|
||||
_style_row_host(
|
||||
_cell_host(_tag_label(prescription_type, "accent", self.table.viewport())),
|
||||
row_index,
|
||||
@@ -903,11 +927,11 @@ class PrescriptionsPage(QWidget):
|
||||
|
||||
audit_text, audit_kind = prescription_status(row)
|
||||
audit = _tag_label(audit_text, audit_kind, self.table.viewport())
|
||||
audit_item = self.table.item(row_index, 5)
|
||||
audit_item = self.table.item(row_index, 6)
|
||||
if audit_item is not None and "\n" in audit_item.text():
|
||||
audit.setToolTip(audit_item.text())
|
||||
self.table.setCellWidget(
|
||||
row_index, 5, _style_row_host(_cell_host(audit), row_index)
|
||||
row_index, 6, _style_row_host(_cell_host(audit), row_index)
|
||||
)
|
||||
|
||||
doctor_name = _doctor_cell(None, row)
|
||||
@@ -928,13 +952,13 @@ class PrescriptionsPage(QWidget):
|
||||
doctor_label.setStyleSheet("color:#31416A;background:transparent;border:0;")
|
||||
doctor_layout.addWidget(doctor_label)
|
||||
doctor_layout.addStretch(1)
|
||||
self.table.setCellWidget(row_index, 7, doctor_host)
|
||||
self.table.setCellWidget(row_index, 8, doctor_host)
|
||||
|
||||
actions_host = QWidget(self.table.viewport())
|
||||
_style_row_host(actions_host, row_index)
|
||||
actions = QHBoxLayout(actions_host)
|
||||
actions.setContentsMargins(7, 0, 7, 0)
|
||||
actions.setSpacing(7)
|
||||
actions.setContentsMargins(3, 0, 3, 0)
|
||||
actions.setSpacing(3)
|
||||
actions.addStretch(1)
|
||||
if has_permission(self.permissions, "cf.prescription/read"):
|
||||
actions.addWidget(
|
||||
@@ -957,6 +981,8 @@ class PrescriptionsPage(QWidget):
|
||||
),
|
||||
actions_host,
|
||||
enabled=can_edit_or_delete(row),
|
||||
label="编辑",
|
||||
mutation=True,
|
||||
)
|
||||
)
|
||||
if has_permission(self.permissions, "cf.prescription/del"):
|
||||
@@ -970,23 +996,31 @@ class PrescriptionsPage(QWidget):
|
||||
actions_host,
|
||||
danger=True,
|
||||
enabled=can_edit_or_delete(row),
|
||||
mutation=True,
|
||||
)
|
||||
)
|
||||
actions.addStretch(1)
|
||||
self.table.setCellWidget(row_index, 10, actions_host)
|
||||
self.table.setCellWidget(row_index, 2, actions_host)
|
||||
self._sync_row_mutation_actions()
|
||||
|
||||
def _run_row_action(self, row: Any, callback: Callable[[], None]) -> None:
|
||||
target_id = first_value(row, "id", "prescription_id", default=None)
|
||||
target_id = _int(first_value(row, "id", "prescription_id", default=None), 0)
|
||||
if target_id <= 0:
|
||||
self.banner.show_message("处方 ID 无效,请刷新列表后重试。", "warning")
|
||||
return
|
||||
for row_index in range(self.table.rowCount()):
|
||||
item = self.table.item(row_index, 0)
|
||||
candidate = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
|
||||
candidate_id = first_value(candidate, "id", "prescription_id", default=None)
|
||||
candidate_id = _int(
|
||||
first_value(candidate, "id", "prescription_id", default=None), 0
|
||||
)
|
||||
if candidate is row or (
|
||||
target_id is not None and str(candidate_id) == str(target_id)
|
||||
candidate_id > 0 and candidate_id == target_id
|
||||
):
|
||||
self.table.selectRow(row_index)
|
||||
break
|
||||
callback()
|
||||
callback()
|
||||
return
|
||||
self.banner.show_message("该处方已不在当前列表,请刷新后重试。", "warning")
|
||||
|
||||
def _load_error(self, error: Exception, generation: int) -> None:
|
||||
if generation == self._generation:
|
||||
@@ -1013,8 +1047,17 @@ class PrescriptionsPage(QWidget):
|
||||
self._mutation_pending = pending
|
||||
self.add_button.setEnabled(not pending)
|
||||
self.orders_button.setEnabled(not pending)
|
||||
self._sync_row_mutation_actions()
|
||||
self._selection_changed()
|
||||
|
||||
def _sync_row_mutation_actions(self) -> None:
|
||||
for button in self.table.findChildren(QPushButton):
|
||||
if not bool(button.property("mutationAction")):
|
||||
continue
|
||||
button.setEnabled(
|
||||
not self._mutation_pending and bool(button.property("rowAllowed"))
|
||||
)
|
||||
|
||||
def _selected(self) -> Any:
|
||||
return self.table.current_data()
|
||||
|
||||
@@ -1054,6 +1097,10 @@ class PrescriptionsPage(QWidget):
|
||||
) -> None:
|
||||
if generation != self._detail_generation or prescription_id != self._detail_target:
|
||||
return
|
||||
detail_id = _int(first_value(detail, "id", "prescription_id"), 0)
|
||||
if detail_id != prescription_id:
|
||||
self.banner.show_message("处方详情与当前选择不一致,请刷新后重试。", "danger")
|
||||
return
|
||||
self.banner.clear()
|
||||
callback(detail)
|
||||
|
||||
@@ -1148,6 +1195,7 @@ class PrescriptionsPage(QWidget):
|
||||
row = self._selected()
|
||||
if (
|
||||
row is None
|
||||
or self._mutation_pending
|
||||
or not has_permission(self.permissions, "cf.prescription/edit")
|
||||
or not can_edit_or_delete(row)
|
||||
):
|
||||
@@ -1159,6 +1207,9 @@ class PrescriptionsPage(QWidget):
|
||||
)
|
||||
|
||||
def _open_editor(self, detail: Any) -> None:
|
||||
if not can_edit_or_delete(detail):
|
||||
self.banner.show_message("该处方状态已变化,当前不能编辑。", "warning")
|
||||
return
|
||||
dialog = PrescriptionEditorDialog(
|
||||
self.repository,
|
||||
detail,
|
||||
@@ -1208,6 +1259,7 @@ class PrescriptionsPage(QWidget):
|
||||
row = self._selected()
|
||||
if (
|
||||
row is None
|
||||
or self._mutation_pending
|
||||
or not has_permission(self.permissions, "cf.prescription/del")
|
||||
or not can_edit_or_delete(row)
|
||||
):
|
||||
@@ -1222,6 +1274,9 @@ class PrescriptionsPage(QWidget):
|
||||
if answer != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
prescription_id = _int(first_value(row, "id", "prescription_id"), 0)
|
||||
if prescription_id <= 0:
|
||||
self.banner.show_message("处方 ID 无效,请刷新列表后重试。", "warning")
|
||||
return
|
||||
self._set_mutation_pending(True)
|
||||
run_async(
|
||||
lambda: self.repository.delete_prescription(prescription_id),
|
||||
|
||||
@@ -79,7 +79,7 @@ from ..dialogs.prescription_ai import (
|
||||
present_diagnosis_ai_assistant,
|
||||
present_diagnosis_ai_report,
|
||||
)
|
||||
from ..theme import mark_business_dialog
|
||||
from ..theme import crisp_pixmap, mark_business_dialog
|
||||
from ..widgets import (
|
||||
EmptyState,
|
||||
MessageBanner,
|
||||
@@ -1656,8 +1656,7 @@ def _bmi_status(bmi: float | None) -> tuple[str, bool] | None:
|
||||
|
||||
|
||||
def _painted_reception_tab_icon(kind: str, size: int = 16) -> QIcon:
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
pixmap = crisp_pixmap(size)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
pen = QPen(QColor("#7481A3"), 1.4)
|
||||
@@ -1698,8 +1697,7 @@ def _painted_reception_tab_icon(kind: str, size: int = 16) -> QIcon:
|
||||
|
||||
|
||||
def _painted_reception_action_icon(kind: str, color: str, size: int = 14) -> QIcon:
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
pixmap = crisp_pixmap(size)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
pen = QPen(QColor(color), 1.4)
|
||||
@@ -4978,12 +4976,9 @@ class ReceptionPage(QWidget):
|
||||
self.ai_analysis_title.setToolTip(
|
||||
f"本次分析:{model_hint}" if model_hint else "服务端 AI 智能分析"
|
||||
)
|
||||
compact_time = generated_at
|
||||
if len(generated_at) >= 16 and generated_at[4:5] in {"-", "/"}:
|
||||
compact_time = generated_at[5:16]
|
||||
meta_text = " | ".join(
|
||||
part for part in (f"第 {version} 版", compact_time) if part
|
||||
)
|
||||
# 标题行放不下“第 N 版 | 08-13 15:42”,时间会被截成“08-13 1…”。
|
||||
# 完整快照时间本来就在下一行的“快照时间:…”里,这里只留版本号。
|
||||
meta_text = f"第 {version} 版"
|
||||
self.ai_analysis_snapshot_meta.setText(meta_text)
|
||||
self.ai_analysis_snapshot_meta.setToolTip(
|
||||
" | ".join(part for part in (f"第 {version} 版", generated_at) if part)
|
||||
|
||||
@@ -40,7 +40,9 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .theme import crisp_pixmap
|
||||
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
|
||||
from .pages import (
|
||||
AppointmentsPage,
|
||||
@@ -109,8 +111,10 @@ class NavigationItem:
|
||||
|
||||
NAVIGATION = (
|
||||
NavigationItem(
|
||||
# 挂号与诊单是两条独立队列,早期两项都叫“问诊列表”,侧边栏出现两个同名
|
||||
# 入口,医生无法判断该点哪个。按各自的业务对象命名以消除歧义。
|
||||
"appointments",
|
||||
"问诊列表",
|
||||
"挂号列表",
|
||||
"号",
|
||||
AppointmentsPage,
|
||||
("doctor.appointment/lists",),
|
||||
@@ -350,8 +354,7 @@ def _resolve_navigation(
|
||||
def _painted_shell_icon(kind: str, size: int = 18) -> QIcon:
|
||||
"""Create a font-independent shell icon once, before widget painting."""
|
||||
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
pixmap = crisp_pixmap(size)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
try:
|
||||
@@ -519,8 +522,7 @@ def _painted_navigation_icon(kind: str, size: int = 18) -> QIcon:
|
||||
"""Return a compact line icon with a dedicated checked-state color."""
|
||||
|
||||
def render(color: str) -> QPixmap:
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
pixmap = crisp_pixmap(size)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
pen = QPen(QColor(color), 1.55)
|
||||
@@ -725,8 +727,7 @@ class _UserMenuButton(QToolButton):
|
||||
self.setFixedWidth(max(104, min(174, name_width + 72)))
|
||||
self.setAccessibleName(f"用户菜单:{self.display_name}")
|
||||
self.setText(self.display_name)
|
||||
avatar = QPixmap(34, 34)
|
||||
avatar.fill(Qt.GlobalColor.transparent)
|
||||
avatar = crisp_pixmap(34)
|
||||
painter = QPainter(avatar)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
@@ -843,6 +844,7 @@ class ShellWindow(QMainWindow):
|
||||
logout_requested = Signal()
|
||||
video_requested = Signal(dict)
|
||||
page_changed = Signal(str)
|
||||
update_check_requested = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -1328,6 +1330,9 @@ class ShellWindow(QMainWindow):
|
||||
account_action = user_menu.addAction(self._role_text())
|
||||
account_action.setEnabled(False)
|
||||
user_menu.addSeparator()
|
||||
update_action = user_menu.addAction("检查更新")
|
||||
update_action.triggered.connect(self.update_check_requested.emit)
|
||||
user_menu.addSeparator()
|
||||
logout_action = user_menu.addAction("退出登录")
|
||||
logout_action.triggered.connect(lambda: self.logout_requested.emit())
|
||||
self.user_menu_button.setMenu(user_menu)
|
||||
@@ -1552,31 +1557,15 @@ class ShellWindow(QMainWindow):
|
||||
show_toast(self, "当前账号没有使用 AI 问诊助手的权限。", "danger")
|
||||
return
|
||||
|
||||
current_page = self.stack.currentWidget()
|
||||
opener = getattr(current_page, "open_selected_ai_consult", None)
|
||||
if callable(opener) and bool(opener()):
|
||||
return
|
||||
|
||||
reception = self.pages.get("reception")
|
||||
if reception is None or not self.navigate("reception"):
|
||||
show_toast(
|
||||
self,
|
||||
"当前账号没有可用的接诊台;请从问诊列表或患者列表选择诊单后使用 AI 分析。",
|
||||
"warning",
|
||||
4800,
|
||||
)
|
||||
return
|
||||
|
||||
if reception is not current_page:
|
||||
reception_opener = getattr(reception, "open_selected_ai_consult", None)
|
||||
if callable(reception_opener) and bool(reception_opener()):
|
||||
return
|
||||
|
||||
show_toast(
|
||||
# The shell assistant is the global entry point. Always let the doctor
|
||||
# choose from the complete permission-scoped patient list, even when a
|
||||
# page happens to retain a selected row. Page-level AI actions keep the
|
||||
# faster "open the selected patient" behaviour.
|
||||
select_and_present_ai_consult(
|
||||
self.repository,
|
||||
self.permissions,
|
||||
self,
|
||||
"请先在接诊台选择一位有诊单的患者,再点击“开始对话”。",
|
||||
"info",
|
||||
4200,
|
||||
initial_query=self.global_search.text().strip(),
|
||||
)
|
||||
|
||||
def _open_local_audio_settings(self) -> None:
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
The palette and density follow the supplied product references: a quiet blue
|
||||
canvas, crisp white data surfaces, luminous indigo actions and compact tables.
|
||||
|
||||
Every size in the interface comes from the token tables below. Before they
|
||||
existed the UI had grown 19 distinct font sizes and 8 control heights, which is
|
||||
what made neighbouring controls look subtly mismatched; keep new work on the
|
||||
scale instead of introducing another one-off pixel value.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,7 +17,7 @@ from pathlib import Path
|
||||
from string import Template
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, Qt
|
||||
from PySide6.QtGui import QColor, QFont, QFontDatabase, QPalette
|
||||
from PySide6.QtGui import QColor, QFont, QFontDatabase, QPalette, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
@@ -66,13 +71,70 @@ COLORS = {
|
||||
}
|
||||
|
||||
|
||||
# --- Type scale -----------------------------------------------------------
|
||||
# Six steps, tuned for 简体中文 at Windows 100%–150% scaling. CJK glyphs carry
|
||||
# more ink than Latin at the same pixel size, so the steps are spaced widely
|
||||
# enough that two adjacent levels are always distinguishable.
|
||||
TYPE = {
|
||||
"fs_caption": "12px", # table headers, hints, badges, timestamps
|
||||
"fs_body": "13px", # default UI text
|
||||
"fs_strong": "14px", # emphasised body, dialog prompts
|
||||
"fs_section": "16px", # card and section titles
|
||||
"fs_title": "20px", # page titles, dialog titles
|
||||
"fs_display": "26px", # metric values, empty-state glyphs
|
||||
}
|
||||
|
||||
# --- Control metrics ------------------------------------------------------
|
||||
# Three interactive heights. ``h_default`` drops from the previous 36px: the
|
||||
# old value made every toolbar, filter row and inline action read as heavy,
|
||||
# which is the main reason the workspace felt clunky.
|
||||
METRICS = {
|
||||
"h_compact": "28px", # inline row actions, chips, links
|
||||
"h_default": "32px", # buttons, inputs, combos, tabs
|
||||
"h_cta": "38px", # primary dialog actions, sidebar navigation
|
||||
"h_bar": "52px", # dialog header / footer bars
|
||||
"r_sm": "6px",
|
||||
"r_md": "8px",
|
||||
"r_lg": "12px",
|
||||
"r_xl": "16px",
|
||||
"pad_control": "12px", # horizontal padding inside default controls
|
||||
"pad_compact": "9px",
|
||||
}
|
||||
|
||||
_QSS_TOKENS = {**COLORS, **TYPE, **METRICS}
|
||||
|
||||
|
||||
def crisp_pixmap(width: int, height: int | None = None) -> QPixmap:
|
||||
"""Return a transparent pixmap that stays sharp on scaled displays.
|
||||
|
||||
Every shell/page icon is painted by hand rather than shipped as an asset.
|
||||
Allocating those pixmaps at their logical size makes Qt upscale them on the
|
||||
125%/150% Windows scale factors most clinic workstations run at, which is
|
||||
what made the icon set look soft next to crisply hinted text. Backing the
|
||||
pixmap with the screen's device pixel ratio keeps the painter in logical
|
||||
coordinates while giving it real device pixels to draw into.
|
||||
"""
|
||||
|
||||
height = width if height is None else height
|
||||
ratio = 1.0
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
screen = app.primaryScreen()
|
||||
if screen is not None:
|
||||
ratio = max(1.0, float(screen.devicePixelRatio()))
|
||||
pixmap = QPixmap(round(width * ratio), round(height * ratio))
|
||||
pixmap.setDevicePixelRatio(ratio)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
return pixmap
|
||||
|
||||
|
||||
GLOBAL_QSS = Template(
|
||||
r"""
|
||||
QWidget {
|
||||
color: $text;
|
||||
background-color: transparent;
|
||||
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
||||
font-size: 13px;
|
||||
font-size: $fs_body;
|
||||
}
|
||||
|
||||
QMainWindow, QDialog, QWidget#LoginCanvas {
|
||||
@@ -89,24 +151,24 @@ QDialog[businessDialog="true"] QFrame[dialogSurface="true"] {
|
||||
}
|
||||
QDialog[businessDialog="true"] QFrame#DialogHeader,
|
||||
QDialog[businessDialog="true"] QFrame[dialogRole="header"] {
|
||||
min-height: 58px;
|
||||
min-height: $h_bar;
|
||||
background-color: $surface;
|
||||
border: 0;
|
||||
border-bottom: 1px solid $line;
|
||||
}
|
||||
QDialog[businessDialog="true"] QLabel[dialogRole="title"] {
|
||||
color: $text;
|
||||
font-size: 18px;
|
||||
font-size: $fs_section;
|
||||
font-weight: 700;
|
||||
}
|
||||
QDialog[businessDialog="true"] QLabel[dialogRole="subtitle"] {
|
||||
color: $muted;
|
||||
font-size: 12px;
|
||||
font-size: $fs_caption;
|
||||
}
|
||||
QDialog[businessDialog="true"] QDialogButtonBox,
|
||||
QMessageBox QDialogButtonBox,
|
||||
QInputDialog QDialogButtonBox {
|
||||
min-height: 58px;
|
||||
min-height: $h_bar;
|
||||
background-color: $surface;
|
||||
border: 0;
|
||||
border-top: 1px solid $line;
|
||||
@@ -115,7 +177,7 @@ QDialog[businessDialog="true"] QDialogButtonBox QPushButton,
|
||||
QMessageBox QDialogButtonBox QPushButton,
|
||||
QInputDialog QDialogButtonBox QPushButton {
|
||||
min-width: 88px;
|
||||
min-height: 38px;
|
||||
min-height: $h_cta;
|
||||
}
|
||||
QMessageBox[businessDialog="true"],
|
||||
QInputDialog[businessDialog="true"] {
|
||||
@@ -125,7 +187,7 @@ QMessageBox QLabel#qt_msgbox_label {
|
||||
min-width: 300px;
|
||||
padding: 8px 2px;
|
||||
color: $text_soft;
|
||||
font-size: 14px;
|
||||
font-size: $fs_strong;
|
||||
}
|
||||
QMessageBox QLabel#qt_msgboxex_icon_label {
|
||||
min-width: 44px;
|
||||
@@ -138,7 +200,7 @@ QMessageBox[messageKind="critical"] QLabel#qt_msgbox_label {
|
||||
}
|
||||
QInputDialog QLabel {
|
||||
color: $text_soft;
|
||||
font-size: 14px;
|
||||
font-size: $fs_strong;
|
||||
}
|
||||
QWidget#AppCanvas {
|
||||
background-color: qlineargradient(
|
||||
@@ -155,33 +217,33 @@ QWidget#ShellWorkspace, QStackedWidget#ShellPageStack {
|
||||
|
||||
QLabel[role="muted"] { color: $muted; }
|
||||
QLabel[role="danger"] { color: $danger; }
|
||||
QLabel[role="breadcrumb"] { color: $muted; font-size: 12px; }
|
||||
QLabel[role="breadcrumbSeparator"] { color: #ADB5C9; font-size: 14px; }
|
||||
QLabel[role="breadcrumbCurrent"] { color: $text_soft; font-size: 12px; font-weight: 600; }
|
||||
QLabel[role="breadcrumb"] { color: $muted; font-size: $fs_caption; }
|
||||
QLabel[role="breadcrumbSeparator"] { color: #ADB5C9; font-size: $fs_strong; }
|
||||
QLabel[role="breadcrumbCurrent"] { color: $text_soft; font-size: $fs_caption; font-weight: 600; }
|
||||
QLabel[role="eyebrow"] {
|
||||
color: $indigo_hover;
|
||||
font-size: 11px;
|
||||
font-size: $fs_caption;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
QLabel[role="pageTitle"] {
|
||||
color: $text;
|
||||
font-size: 22px;
|
||||
font-size: $fs_title;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel[role="sectionTitle"] {
|
||||
color: $text;
|
||||
font-size: 16px;
|
||||
font-size: $fs_section;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel[role="display"] {
|
||||
color: $text;
|
||||
font-size: 30px;
|
||||
font-size: $fs_display;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel[role="metric"] {
|
||||
color: $text;
|
||||
font-size: 22px;
|
||||
font-size: $fs_title;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -203,9 +265,9 @@ QFrame#MetricCard {
|
||||
border-radius: 11px;
|
||||
}
|
||||
QFrame#MetricCard:hover { border-color: $line_soft; background-color: $surface_alt; }
|
||||
QFrame#MetricCard QLabel[role="metricTitle"] { color: $muted; font-size: 12px; }
|
||||
QFrame#MetricCard QLabel[role="metricValue"] { color: $text; font-size: 21px; font-weight: 700; }
|
||||
QFrame#MetricCard QLabel[role="metricHint"] { color: $muted; font-size: 11px; }
|
||||
QFrame#MetricCard QLabel[role="metricTitle"] { color: $muted; font-size: $fs_caption; }
|
||||
QFrame#MetricCard QLabel[role="metricValue"] { color: $text; font-size: $fs_title; font-weight: 700; }
|
||||
QFrame#MetricCard QLabel[role="metricHint"] { color: $muted; font-size: $fs_caption; }
|
||||
QFrame#ReceptionDetailPanel { background-color: transparent; border: 0; }
|
||||
QFrame#ReceptionAiCard {
|
||||
min-height: 132px;
|
||||
@@ -215,20 +277,20 @@ QFrame#ReceptionAiCard {
|
||||
}
|
||||
QLabel#ReceptionAiTitle {
|
||||
color: $indigo_pressed;
|
||||
font-size: 14px;
|
||||
font-size: $fs_strong;
|
||||
font-weight: 700;
|
||||
}
|
||||
QFrame#ReceptionAiCard QPushButton[variant="secondary"] {
|
||||
min-height: 30px;
|
||||
min-height: $h_compact;
|
||||
padding: 0 9px;
|
||||
font-size: 11px;
|
||||
font-size: $fs_caption;
|
||||
}
|
||||
QLabel#MetricGlyph {
|
||||
color: $indigo;
|
||||
background-color: $indigo_pale;
|
||||
border: 1px solid $line_soft;
|
||||
border-radius: 11px;
|
||||
font-size: 16px;
|
||||
font-size: $fs_section;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel#MetricGlyph[kind="success"] { color: $success; background-color: $success_pale; }
|
||||
@@ -255,10 +317,10 @@ QGroupBox::title {
|
||||
}
|
||||
|
||||
QPushButton {
|
||||
min-height: 36px;
|
||||
padding: 0 16px;
|
||||
min-height: $h_default;
|
||||
padding: 0 $pad_control;
|
||||
border: 1px solid $line;
|
||||
border-radius: 9px;
|
||||
border-radius: $r_md;
|
||||
background-color: $surface;
|
||||
color: $text;
|
||||
font-weight: 600;
|
||||
@@ -328,6 +390,50 @@ QPushButton[variant="secondary"]:checked {
|
||||
border-color: $indigo_pressed;
|
||||
}
|
||||
|
||||
/* Inline table row actions: quiet links that only gain a surface on hover, so
|
||||
a column of them reads as text rather than as a wall of buttons. */
|
||||
QWidget#RowActions QPushButton[rowAction="true"] {
|
||||
min-height: $h_compact;
|
||||
padding: 0 $pad_compact;
|
||||
color: $info;
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $r_sm;
|
||||
font-weight: 600;
|
||||
}
|
||||
QWidget#RowActions QPushButton[rowAction="true"]:hover {
|
||||
color: $indigo_pressed;
|
||||
background-color: $indigo_pale;
|
||||
border-color: $line_soft;
|
||||
}
|
||||
QWidget#RowActions QPushButton[rowAction="true"]:pressed {
|
||||
color: #FFFFFF;
|
||||
background-color: $indigo_pressed;
|
||||
border-color: $indigo_pressed;
|
||||
}
|
||||
QWidget#RowActions QPushButton[rowAction="true"][variant="dangerGhost"] { color: $danger; }
|
||||
QWidget#RowActions QPushButton[rowAction="true"][variant="dangerGhost"]:hover {
|
||||
color: $danger;
|
||||
background-color: $danger_pale;
|
||||
border-color: rgba(240, 120, 134, 96);
|
||||
}
|
||||
QToolButton#RowActionsMore {
|
||||
min-width: 0;
|
||||
min-height: $h_compact;
|
||||
padding: 0 $pad_compact;
|
||||
color: $muted;
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $r_sm;
|
||||
font-weight: 600;
|
||||
}
|
||||
QToolButton#RowActionsMore:hover {
|
||||
color: $text;
|
||||
background-color: $surface_alt;
|
||||
border-color: $line;
|
||||
}
|
||||
QToolButton#RowActionsMore::menu-indicator { width: 0; height: 0; image: none; }
|
||||
|
||||
QFrame#NoteAttachmentTile {
|
||||
background-color: $surface;
|
||||
border: 1px solid $line;
|
||||
@@ -352,7 +458,7 @@ QPushButton#NoteAttachmentPreview[loadState="failed"] {
|
||||
}
|
||||
QLabel#NoteAttachmentName {
|
||||
color: $text_soft;
|
||||
font-size: 12px;
|
||||
font-size: $fs_caption;
|
||||
}
|
||||
|
||||
QPushButton[variant="danger"] {
|
||||
@@ -413,8 +519,8 @@ QPushButton[variant="ghost"]:checked {
|
||||
border-color: $indigo_pressed;
|
||||
}
|
||||
QPushButton[variant="link"] {
|
||||
min-height: 28px;
|
||||
padding: 0 6px;
|
||||
min-height: $h_compact;
|
||||
padding: 0 $pad_compact;
|
||||
color: $info;
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
@@ -422,8 +528,8 @@ QPushButton[variant="link"] {
|
||||
QPushButton[variant="link"]:hover { color: $focus; background-color: $indigo_pale; }
|
||||
QPushButton[variant="link"]:pressed { color: $indigo_hover; background-color: $surface_alt; }
|
||||
QPushButton[variant="chip"] {
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
min-height: $h_compact;
|
||||
padding: 0 $pad_compact;
|
||||
color: $text_soft;
|
||||
background-color: $surface_alt;
|
||||
border-color: $line;
|
||||
@@ -440,10 +546,10 @@ QPushButton[variant="chip"]:checked {
|
||||
border-color: $indigo_hover;
|
||||
}
|
||||
QPushButton[variant="nav"] {
|
||||
min-height: 44px;
|
||||
padding: 0 15px;
|
||||
min-height: $h_cta;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
border-radius: $r_md;
|
||||
background-color: transparent;
|
||||
color: $muted;
|
||||
text-align: left;
|
||||
@@ -454,10 +560,10 @@ QPushButton[variant="nav"]:pressed { background-color: $indigo_pale; }
|
||||
QPushButton[variant="nav"]:checked { background-color: $indigo_pressed; color: #FFFFFF; }
|
||||
|
||||
QToolButton {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
min-width: $h_default;
|
||||
min-height: $h_default;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
border-radius: $r_md;
|
||||
color: $text_soft;
|
||||
background-color: transparent;
|
||||
}
|
||||
@@ -483,16 +589,16 @@ QToolButton[diagnosisChip="true"][semantic="warning"] { color: $warning; backgro
|
||||
|
||||
QLineEdit, QTextEdit, QPlainTextEdit, QComboBox, QDateEdit, QDateTimeEdit,
|
||||
QTimeEdit, QSpinBox, QDoubleSpinBox, QKeySequenceEdit {
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
min-height: $h_default;
|
||||
padding: 0 $pad_control;
|
||||
border: 1px solid $line;
|
||||
border-radius: 9px;
|
||||
border-radius: $r_md;
|
||||
background-color: $surface;
|
||||
color: $text;
|
||||
selection-background-color: $indigo;
|
||||
selection-color: #FFFFFF;
|
||||
}
|
||||
QTextEdit, QPlainTextEdit { padding: 9px 12px; }
|
||||
QTextEdit, QPlainTextEdit { padding: 7px $pad_control; }
|
||||
QLineEdit:hover, QTextEdit:hover, QPlainTextEdit:hover, QComboBox:hover,
|
||||
QDateEdit:hover, QDateTimeEdit:hover, QTimeEdit:hover, QSpinBox:hover,
|
||||
QDoubleSpinBox:hover, QKeySequenceEdit:hover { border-color: $indigo_hover; }
|
||||
@@ -569,7 +675,7 @@ QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget
|
||||
}
|
||||
QAbstractItemView:focus { border: 1px solid $indigo_hover; }
|
||||
QTableWidget::item, QTableView::item {
|
||||
padding: 8px 8px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid $line;
|
||||
}
|
||||
QTableWidget::item:hover, QTableView::item:hover { background-color: $surface_alt; }
|
||||
@@ -583,16 +689,16 @@ QHeaderView::section {
|
||||
border: 0;
|
||||
border-right: 1px solid $line;
|
||||
border-bottom: 1px solid $line;
|
||||
padding: 9px 8px;
|
||||
font-size: 12px;
|
||||
padding: 7px 8px;
|
||||
font-size: $fs_caption;
|
||||
font-weight: 700;
|
||||
}
|
||||
QHeaderView::section:hover { color: $text; background-color: $raised; }
|
||||
QTableCornerButton::section { background-color: $surface_alt; border: 0; }
|
||||
QListWidget::item, QListView::item, QTreeWidget::item, QTreeView::item {
|
||||
border: 0;
|
||||
padding: 7px 9px;
|
||||
margin: 2px 0;
|
||||
padding: 6px $pad_compact;
|
||||
margin: 1px 0;
|
||||
}
|
||||
QListWidget::item:selected, QListView::item:selected,
|
||||
QTreeWidget::item:selected, QTreeView::item:selected {
|
||||
@@ -612,9 +718,9 @@ QTabWidget::pane {
|
||||
background-color: $glass;
|
||||
}
|
||||
QTabBar::tab {
|
||||
min-height: 36px;
|
||||
padding: 0 16px;
|
||||
margin-right: 4px;
|
||||
min-height: $h_default;
|
||||
padding: 0 14px;
|
||||
margin-right: 2px;
|
||||
color: $muted;
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
@@ -634,7 +740,7 @@ QTabBar#ReceptionDetailTabs {
|
||||
border-bottom: 1px solid $line;
|
||||
}
|
||||
QTabBar#ReceptionDetailTabs::tab {
|
||||
min-height: 38px;
|
||||
min-height: $h_cta;
|
||||
padding: 0 14px;
|
||||
margin: 0 4px 0 0;
|
||||
color: $muted;
|
||||
@@ -665,10 +771,10 @@ QMenu {
|
||||
padding: 6px;
|
||||
}
|
||||
QMenu::item {
|
||||
min-width: 112px;
|
||||
min-height: 32px;
|
||||
padding: 0 12px 0 30px;
|
||||
border-radius: 7px;
|
||||
min-width: 128px;
|
||||
min-height: $h_default;
|
||||
padding: 0 14px 0 30px;
|
||||
border-radius: $r_sm;
|
||||
background-color: transparent;
|
||||
}
|
||||
QMenu::item:selected { color: #FFFFFF; background-color: $indigo_pressed; }
|
||||
@@ -721,7 +827,7 @@ QScrollBar:vertical {
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: $line;
|
||||
min-height: 30px;
|
||||
min-height: $h_compact;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QScrollBar::handle:vertical:hover { background: $indigo_pressed; }
|
||||
@@ -764,10 +870,10 @@ QSlider::handle:horizontal {
|
||||
QSlider::handle:horizontal:hover { border-color: $focus; }
|
||||
|
||||
QLabel#StatusBadge {
|
||||
padding: 4px 9px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
border-radius: $r_sm;
|
||||
font-size: $fs_caption;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel#StatusBadge[kind="neutral"] { color: $muted; background-color: $surface_alt; border-color: $line; }
|
||||
@@ -778,18 +884,18 @@ QLabel#StatusBadge[kind="info"] { color: $info; background-color: $info_pale; bo
|
||||
QLabel#StatusBadge[kind="accent"] { color: $indigo_hover; background-color: $indigo_pale; border-color: $line_soft; }
|
||||
|
||||
QWidget#Pager QLabel#PagerActive {
|
||||
min-width: 42px;
|
||||
min-height: 30px;
|
||||
min-width: 32px;
|
||||
min-height: $h_compact;
|
||||
color: #FFFFFF;
|
||||
background-color: $indigo;
|
||||
border: 1px solid $indigo;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-size: $fs_caption;
|
||||
font-weight: 700;
|
||||
}
|
||||
QWidget#Pager QPushButton {
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
min-height: $h_compact;
|
||||
padding: 0 $pad_compact;
|
||||
border: 1px solid $line;
|
||||
background-color: $surface;
|
||||
}
|
||||
@@ -800,7 +906,7 @@ QLabel#EmptyStateGlyph {
|
||||
background-color: $indigo_pale;
|
||||
border: 1px solid $line_soft;
|
||||
border-radius: 22px;
|
||||
font-size: 28px;
|
||||
font-size: $fs_display;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -854,13 +960,13 @@ QFrame#TopBar, QFrame#MultipleTabs {
|
||||
QLabel#UserAvatar {
|
||||
min-width: 36px;
|
||||
max-width: 36px;
|
||||
min-height: 36px;
|
||||
max-height: 36px;
|
||||
min-height: $h_default;
|
||||
max-height: $h_default;
|
||||
color: #FFFFFF;
|
||||
background-color: $indigo_pressed;
|
||||
border: 1px solid $line_soft;
|
||||
border-radius: 18px;
|
||||
font-size: 15px;
|
||||
font-size: $fs_strong;
|
||||
font-weight: 700;
|
||||
}
|
||||
QWidget#LoginBrandPanel {
|
||||
@@ -888,7 +994,7 @@ QToolTip {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
"""
|
||||
).substitute(COLORS)
|
||||
).substitute(_QSS_TOKENS)
|
||||
|
||||
|
||||
def _apply_group(
|
||||
@@ -1107,4 +1213,12 @@ def apply_theme(app: QApplication) -> None:
|
||||
_install_business_dialog_styling(app)
|
||||
|
||||
|
||||
__all__ = ["COLORS", "GLOBAL_QSS", "apply_theme", "mark_business_dialog"]
|
||||
__all__ = [
|
||||
"COLORS",
|
||||
"GLOBAL_QSS",
|
||||
"METRICS",
|
||||
"TYPE",
|
||||
"apply_theme",
|
||||
"crisp_pixmap",
|
||||
"mark_business_dialog",
|
||||
]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
import traceback
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
@@ -16,10 +17,12 @@ from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
@@ -91,6 +94,43 @@ def display_text(value: Any, default: str = "—") -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
_UNIX_TIMESTAMP_RE = re.compile(r"\d{10,13}(?:\.\d+)?")
|
||||
_RECORD_TIME_FORMAT = "%Y-%m-%d %H:%M"
|
||||
|
||||
|
||||
def format_record_time(value: Any, default: str = "—") -> str:
|
||||
"""Format a record timestamp that may arrive as Unix seconds, milliseconds, or ISO text.
|
||||
|
||||
The desktop pages used to render raw ``str(int)`` for fields like
|
||||
``create_time`` whenever the server returned a numeric epoch. This
|
||||
helper keeps the legacy strings (already formatted dates) untouched
|
||||
while normalising the numeric and ISO variants to ``YYYY-MM-DD HH:MM``
|
||||
so the column reads as a real creation date.
|
||||
"""
|
||||
|
||||
if value is None or value == "":
|
||||
return default
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime(_RECORD_TIME_FORMAT)
|
||||
if isinstance(value, date):
|
||||
return value.strftime("%Y-%m-%d")
|
||||
raw = str(value).strip()
|
||||
if not raw:
|
||||
return default
|
||||
if _UNIX_TIMESTAMP_RE.fullmatch(raw):
|
||||
stamp = float(raw)
|
||||
if stamp >= 10_000_000_000:
|
||||
stamp /= 1000.0
|
||||
try:
|
||||
return datetime.fromtimestamp(stamp).strftime(_RECORD_TIME_FORMAT)
|
||||
except (OSError, OverflowError, ValueError):
|
||||
return raw
|
||||
normalized = raw.replace("T", " ").replace("Z", "")
|
||||
if len(normalized) >= 16:
|
||||
return normalized[:16]
|
||||
return raw
|
||||
|
||||
|
||||
def gender_text(value: Any, default: str = "—") -> str:
|
||||
"""Format the legacy gender codes without exposing numeric API values."""
|
||||
|
||||
@@ -375,6 +415,11 @@ def friendly_error(error: Any) -> str:
|
||||
return "当前账号无权执行此操作。"
|
||||
if "not found" in lowered:
|
||||
return "未找到所需数据。"
|
||||
# 友好的 Dify / AI 上游错误映射。原文来自服务端 DifyChatService 的 error_code 分支,
|
||||
# 当上游拒绝、超时或 quota 受限时,原文对医生不友好,按场景给出可执行引导。
|
||||
upstream_hint = _ai_upstream_hint(text)
|
||||
if upstream_hint is not None:
|
||||
return upstream_hint
|
||||
if any("\u4e00" <= character <= "\u9fff" for character in text):
|
||||
return text
|
||||
if isinstance(error, TypeError):
|
||||
@@ -382,6 +427,38 @@ def friendly_error(error: Any) -> str:
|
||||
return "操作未完成,请稍后重试。"
|
||||
|
||||
|
||||
_AI_UPSTREAM_HINTS: tuple[tuple[str, str], ...] = (
|
||||
("模型未能处理本次请求", "AI 助手暂时无法处理本次请求,请稍后重试;若多次出现请联系管理员检查 AI 服务配置。"),
|
||||
("模型响应超时", "AI 助手响应超时,请稍后重试。"),
|
||||
("暂时无法连接 AI 服务", "无法连接 AI 助手服务,请检查网络或稍后重试。"),
|
||||
("模型服务繁忙", "AI 助手服务繁忙,请稍后重试。"),
|
||||
("AI 助手未返回内容", "AI 助手未返回内容,请稍后重试。"),
|
||||
("AI 助手暂时不可用", "AI 助手暂时不可用,请稍后重试。"),
|
||||
("病例数据编码失败", "病例数据无法发送到 AI 助手,请联系管理员。"),
|
||||
("无法初始化 AI 请求", "无法初始化 AI 助手请求,请联系管理员。"),
|
||||
("AI 服务凭据无效或无权限", "AI 助手服务凭据无效或无权限,请联系管理员。"),
|
||||
("AI 服务配置无效", "AI 助手服务配置无效,请联系管理员。"),
|
||||
("该模型服务尚未完整配置", "AI 助手服务尚未完整配置,请联系管理员。"),
|
||||
("AI 报告功能未启用", "AI 助手功能尚未启用,请联系管理员。"),
|
||||
("不支持的 AI 模型", "AI 助手模型不受支持,请联系管理员。"),
|
||||
)
|
||||
|
||||
|
||||
_AI_UPSTREAM_CODE = re.compile(r"[((]([A-Z][A-Z0-9_]{2,39})[))]")
|
||||
|
||||
|
||||
def _ai_upstream_hint(text: str) -> str | None:
|
||||
for marker, hint in _AI_UPSTREAM_HINTS:
|
||||
if marker in text:
|
||||
# 服务端会在消息尾部附带上游错误码。保留它,医生截图反馈时管理员
|
||||
# 能直接区分是配置问题、资料体积超限还是上游拒绝。
|
||||
match = _AI_UPSTREAM_CODE.search(text)
|
||||
if match is not None:
|
||||
return f"{hint}({match.group(1)})"
|
||||
return hint
|
||||
return None
|
||||
|
||||
|
||||
class PageHeader(QWidget):
|
||||
"""Reference-design page heading with breadcrumb, copy and actions."""
|
||||
|
||||
@@ -762,6 +839,92 @@ class SortableTable(QTableWidget):
|
||||
return item.data(Qt.ItemDataRole.UserRole) if item is not None else None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RowAction:
|
||||
"""One entry in a table row's action cell."""
|
||||
|
||||
label: str
|
||||
callback: Callable[[], None]
|
||||
danger: bool = False
|
||||
#: Keep this action inline even when it would otherwise overflow.
|
||||
pinned: bool = False
|
||||
|
||||
|
||||
class RowActions(QWidget):
|
||||
"""Row action cell that shows a couple of buttons and hides the rest.
|
||||
|
||||
List rows used to render every permitted action as its own small button —
|
||||
up to seven on the diagnosis list. That widened the action column, pushed
|
||||
the data columns into ellipsis, and left each row a different width, which
|
||||
is what made the tables feel heavy and misaligned. Only the first
|
||||
``max_visible`` non-destructive actions stay inline now; everything else,
|
||||
destructive actions included, moves into a single 更多 menu.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
actions: Sequence[RowAction],
|
||||
parent: QWidget | None = None,
|
||||
*,
|
||||
max_visible: int = 2,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("RowActions")
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(4, 2, 4, 2)
|
||||
layout.setSpacing(2)
|
||||
|
||||
inline: list[RowAction] = []
|
||||
overflow: list[RowAction] = []
|
||||
for action in actions:
|
||||
room_left = len(inline) < max_visible
|
||||
if action.pinned or (room_left and not action.danger):
|
||||
inline.append(action)
|
||||
else:
|
||||
overflow.append(action)
|
||||
|
||||
self.buttons: list[QPushButton] = []
|
||||
for action in inline:
|
||||
button = QPushButton(action.label, self)
|
||||
button.setProperty("rowAction", True)
|
||||
if action.danger:
|
||||
button.setProperty("variant", "dangerGhost")
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button.clicked.connect(lambda _checked=False, run=action.callback: run())
|
||||
layout.addWidget(button)
|
||||
self.buttons.append(button)
|
||||
|
||||
self.more_button: QToolButton | None = None
|
||||
if overflow:
|
||||
more = QToolButton(self)
|
||||
more.setObjectName("RowActionsMore")
|
||||
more.setText("更多")
|
||||
more.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||
more.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
menu = QMenu(more)
|
||||
for action in overflow:
|
||||
entry = menu.addAction(action.label)
|
||||
if action.danger:
|
||||
entry.setProperty("danger", True)
|
||||
entry.triggered.connect(lambda _checked=False, run=action.callback: run())
|
||||
more.setMenu(menu)
|
||||
layout.addWidget(more)
|
||||
self.more_button = more
|
||||
self.menu = menu
|
||||
|
||||
layout.addStretch(1)
|
||||
|
||||
@property
|
||||
def visible_labels(self) -> list[str]:
|
||||
return [button.text() for button in self.buttons]
|
||||
|
||||
@property
|
||||
def overflow_labels(self) -> list[str]:
|
||||
if self.more_button is None:
|
||||
return []
|
||||
return [entry.text() for entry in self.more_button.menu().actions()]
|
||||
|
||||
|
||||
class BusinessPager(QWidget):
|
||||
"""Compact numbered pager shared by dense business-list pages."""
|
||||
|
||||
@@ -861,51 +1024,19 @@ class BusinessPager(QWidget):
|
||||
self.page_changed.emit(page)
|
||||
|
||||
|
||||
class Pager(QWidget):
|
||||
page_changed = Signal(int)
|
||||
class Pager(BusinessPager):
|
||||
"""Numbered pager for the appointment/patient lists.
|
||||
|
||||
This used to be a separate ``上一页 / 1 / 1 / 下一页`` control, so the
|
||||
workstation shipped three different pagination footers depending on which
|
||||
list you opened. It now reuses :class:`BusinessPager` verbatim and only
|
||||
keeps its own object name and default page size, giving every list the same
|
||||
footer while leaving the ``page_changed`` / ``update_state`` API unchanged.
|
||||
"""
|
||||
|
||||
def __init__(self, page_size: int = 20, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
super().__init__(page_size, parent)
|
||||
self.setObjectName("Pager")
|
||||
self.page = 1
|
||||
self.page_size = page_size
|
||||
self.total = 0
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 4, 0, 0)
|
||||
self.summary = QLabel("共 0 条")
|
||||
self.summary.setProperty("role", "muted")
|
||||
layout.addWidget(self.summary)
|
||||
layout.addStretch(1)
|
||||
self.previous = QPushButton("上一页")
|
||||
self.previous.setProperty("variant", "ghost")
|
||||
self.previous.clicked.connect(lambda: self._request(self.page - 1))
|
||||
layout.addWidget(self.previous)
|
||||
self.page_label = QLabel("1 / 1")
|
||||
self.page_label.setObjectName("PagerActive")
|
||||
self.page_label.setMinimumWidth(58)
|
||||
self.page_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(self.page_label)
|
||||
self.next = QPushButton("下一页")
|
||||
self.next.setProperty("variant", "ghost")
|
||||
self.next.clicked.connect(lambda: self._request(self.page + 1))
|
||||
layout.addWidget(self.next)
|
||||
self.update_state(1, 0)
|
||||
|
||||
@property
|
||||
def page_count(self) -> int:
|
||||
return max(1, (self.total + self.page_size - 1) // self.page_size)
|
||||
|
||||
def update_state(self, page: int, total: int) -> None:
|
||||
self.page = max(1, page)
|
||||
self.total = max(0, total)
|
||||
self.summary.setText(f"共 {self.total} 条")
|
||||
self.page_label.setText(f"{self.page} / {self.page_count}")
|
||||
self.previous.setEnabled(self.page > 1)
|
||||
self.next.setEnabled(self.page < self.page_count)
|
||||
|
||||
def _request(self, page: int) -> None:
|
||||
if 1 <= page <= self.page_count and page != self.page:
|
||||
self.page_changed.emit(page)
|
||||
|
||||
|
||||
def card_layout(card: QFrame, margins: int = 18, spacing: int = 12) -> QVBoxLayout:
|
||||
@@ -957,6 +1088,7 @@ __all__ = [
|
||||
"clear_layout",
|
||||
"display_text",
|
||||
"first_value",
|
||||
"format_record_time",
|
||||
"friendly_error",
|
||||
"get_value",
|
||||
"has_permission",
|
||||
|
||||
Reference in New Issue
Block a user