This commit is contained in:
Your Name
2026-08-22 08:51:35 +08:00
parent 6c444a4a04
commit c06d293424
69 changed files with 11431 additions and 1601 deletions
+8
View File
@@ -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:
+12 -6
View File
@@ -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()):
+20 -26
View File
@@ -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)
+20 -31
View File
@@ -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:
+184 -70
View File
@@ -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",
]
+174 -42
View File
@@ -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",