更新
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 73 KiB |
@@ -169,8 +169,30 @@ try {
|
||||
& $Npm run build --prefix $CompanionRoot
|
||||
if ($LASTEXITCODE -ne 0) { throw "video companion build failed" }
|
||||
|
||||
& $Python -m PyInstaller --noconfirm --clean $Spec
|
||||
if ($LASTEXITCODE -ne 0) { throw "PyInstaller build failed" }
|
||||
$BuildPythonBase = (& $Python -c "import sys; print(sys.base_prefix)").Trim()
|
||||
if ($LASTEXITCODE -ne 0 -or -not $BuildPythonBase) {
|
||||
throw "Unable to resolve the build Python runtime directory"
|
||||
}
|
||||
# Dependency scanning must not collect unrelated ICU/OpenSSL libraries from
|
||||
# an editor's helper tools (for example Poppler) ahead of Windows libraries.
|
||||
$PreviousBuildPath = $env:PATH
|
||||
$BuildRuntimePaths = @(
|
||||
(Split-Path -Parent $Python),
|
||||
$BuildPythonBase,
|
||||
(Join-Path $BuildPythonBase "DLLs"),
|
||||
(Join-Path $env:SystemRoot "System32"),
|
||||
$env:SystemRoot,
|
||||
(Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0")
|
||||
)
|
||||
try {
|
||||
$env:PATH = ($BuildRuntimePaths | Select-Object -Unique) -join [System.IO.Path]::PathSeparator
|
||||
& $Python -m PyInstaller --noconfirm --clean $Spec
|
||||
$PyInstallerExitCode = $LASTEXITCODE
|
||||
}
|
||||
finally {
|
||||
$env:PATH = $PreviousBuildPath
|
||||
}
|
||||
if ($PyInstallerExitCode -ne 0) { throw "PyInstaller build failed" }
|
||||
|
||||
$Artifact = Join-Path $ProjectRoot "dist\DoctorWorkstation"
|
||||
$Helper = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "QtWebEngineProcess.exe" -File | Select-Object -First 1
|
||||
|
||||
@@ -8,10 +8,10 @@ from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtGui import QFont, QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui import apply_theme
|
||||
from doctor_workstation.ui.diagnosis_editors import DailyRecordEditorDialog
|
||||
from doctor_workstation.ui.diagnosis_media import RecordingPlayerDialog
|
||||
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
|
||||
@@ -480,12 +480,7 @@ def _run_immediately(
|
||||
|
||||
def render() -> list[Path]:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
font_path = Path("C:/Windows/Fonts/msyh.ttc")
|
||||
if font_path.is_file():
|
||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
if families:
|
||||
app.setFont(QFont(families[0], 9))
|
||||
apply_theme(app)
|
||||
diagnosis_module.run_async = _run_immediately
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
output = root / "artifacts" / "diagnosis_visual"
|
||||
|
||||
@@ -8,7 +8,6 @@ from pathlib import Path
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import QThreadPool
|
||||
from PySide6.QtGui import QFont, QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
@@ -19,13 +18,6 @@ def render() -> list[Path]:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
|
||||
font_path = Path("C:/Windows/Fonts/msyh.ttc")
|
||||
if font_path.is_file():
|
||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
if families:
|
||||
app.setFont(QFont(families[0], 9))
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
output = root / "artifacts" / "diagnosis_visual"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -9,10 +9,11 @@ from typing import Any
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import Qt, QThreadPool, Signal
|
||||
from PySide6.QtGui import QColor, QFont, QFontDatabase, QImage, QPainter, QPixmap
|
||||
from PySide6.QtGui import QColor, QImage, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import QApplication, QToolButton, QWidget
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.ui import apply_theme
|
||||
from doctor_workstation.ui.pages import consultations as consultations_module
|
||||
from doctor_workstation.ui.pages.consultations import ConsultationsPage
|
||||
|
||||
@@ -339,14 +340,7 @@ def _save_with_payment_qr(
|
||||
|
||||
def _application() -> QApplication:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
# The offscreen Windows plugin does not enumerate system fonts. Register
|
||||
# the same CJK face used by the production QSS when it is available.
|
||||
font_path = Path("C:/Windows/Fonts/msyh.ttc")
|
||||
if font_path.is_file():
|
||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
if families:
|
||||
app.setFont(QFont(families[0], 9))
|
||||
apply_theme(app)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtGui import QFont, QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
from doctor_workstation.core.permissions import PermissionSet
|
||||
@@ -136,12 +135,6 @@ def _settle(app: QApplication, rounds: int = 8) -> None:
|
||||
def render() -> list[Path]:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
apply_theme(app)
|
||||
font_path = Path("C:/Windows/Fonts/msyh.ttc")
|
||||
if font_path.is_file():
|
||||
font_id = QFontDatabase.addApplicationFont(str(font_path))
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
if families:
|
||||
app.setFont(QFont(families[0], 9))
|
||||
|
||||
output = Path(__file__).resolve().parents[1] / "artifacts" / "subwindow_exact"
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -8,7 +8,6 @@ from pathlib import Path
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import QThreadPool
|
||||
from PySide6.QtGui import QFontDatabase
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
@@ -19,9 +18,6 @@ from doctor_workstation.ui.theme import apply_theme
|
||||
def main() -> int:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
font_path = Path(r"C:\Windows\Fonts\msyh.ttc")
|
||||
if font_path.is_file():
|
||||
QFontDatabase.addApplicationFont(str(font_path))
|
||||
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login("doctor", "doctor123")
|
||||
|
||||
+380
-380
@@ -5,9 +5,9 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from contextlib import suppress
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from contextlib import suppress
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QLibraryInfo, QLocale, QObject, Qt, QTimer, QTranslator
|
||||
@@ -27,7 +27,7 @@ from doctor_workstation.config import AppConfig
|
||||
from doctor_workstation.core import Session
|
||||
from doctor_workstation.core.errors import AuthenticationExpiredError
|
||||
from doctor_workstation.logging_setup import configure_logging
|
||||
from doctor_workstation.resources import app_icon_path, video_dist_path
|
||||
from doctor_workstation.resources import app_icon_path, video_dist_path
|
||||
from doctor_workstation.services import (
|
||||
DemoDoctorRepository,
|
||||
RemoteDoctorRepository,
|
||||
@@ -35,179 +35,179 @@ 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 (
|
||||
first_value,
|
||||
friendly_error,
|
||||
gender_text,
|
||||
get_value,
|
||||
run_async,
|
||||
from doctor_workstation.ui.dialogs.app_update import AppUpdateSession
|
||||
from doctor_workstation.ui.widgets import (
|
||||
first_value,
|
||||
friendly_error,
|
||||
gender_text,
|
||||
get_value,
|
||||
run_async,
|
||||
set_authentication_expired_handler,
|
||||
show_toast,
|
||||
)
|
||||
from doctor_workstation.video import BackendMode, launch_video_call
|
||||
from doctor_workstation.video.window import WEBENGINE_AVAILABLE
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _video_case_text(value: Any, *, limit: int = 2000) -> str:
|
||||
"""Render a bounded, JSON-safe clinical value for the trusted call rail."""
|
||||
|
||||
if value in (None, "", [], {}):
|
||||
return ""
|
||||
if isinstance(value, Mapping):
|
||||
parts = [
|
||||
f"{key}:{_video_case_text(item, limit=limit)}"
|
||||
for key, item in value.items()
|
||||
if item not in (None, "", [], {})
|
||||
]
|
||||
return ";".join(part for part in parts if not part.endswith(":"))[:limit]
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return "、".join(
|
||||
part
|
||||
for item in value
|
||||
if (part := _video_case_text(item, limit=limit))
|
||||
)[:limit]
|
||||
return str(value).strip()[:limit]
|
||||
|
||||
|
||||
def _video_identity(value: Any) -> str:
|
||||
if value in (None, "") or isinstance(value, bool):
|
||||
return ""
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return ""
|
||||
with suppress(ValueError, TypeError):
|
||||
return str(int(text))
|
||||
return text
|
||||
|
||||
|
||||
def _video_identity_matches(expected: Any, actual: Any) -> bool:
|
||||
normalized_actual = _video_identity(actual)
|
||||
return not normalized_actual or normalized_actual == _video_identity(expected)
|
||||
|
||||
|
||||
def _build_video_patient_case(
|
||||
detail: Any,
|
||||
fallback_record: Any,
|
||||
*,
|
||||
diagnosis_id: Any,
|
||||
patient_id: Any,
|
||||
patient_name: str,
|
||||
) -> dict[str, str]:
|
||||
"""Reduce the readonly diagnosis aggregate to the fields needed in-call."""
|
||||
|
||||
if isinstance(detail, Mapping) and not get_value(detail, "diagnosis", None):
|
||||
nested = get_value(detail, "data", None)
|
||||
if isinstance(nested, Mapping):
|
||||
detail = nested
|
||||
diagnosis = get_value(detail, "diagnosis", None)
|
||||
if not diagnosis and isinstance(detail, Mapping):
|
||||
diagnosis = detail
|
||||
diagnosis = diagnosis or {}
|
||||
patient = get_value(detail, "patient", None) or {}
|
||||
appointment = get_value(detail, "appointment", None) or {}
|
||||
|
||||
detail_diagnosis_id = first_value(diagnosis, "id", "diagnosis_id", default=None)
|
||||
detail_patient_id = first_value(
|
||||
diagnosis,
|
||||
"source_patient_id",
|
||||
default=first_value(
|
||||
patient,
|
||||
"source_patient_id",
|
||||
default=None,
|
||||
),
|
||||
)
|
||||
if not _video_identity_matches(
|
||||
diagnosis_id,
|
||||
detail_diagnosis_id,
|
||||
) or not _video_identity_matches(patient_id, detail_patient_id):
|
||||
detail = diagnosis = patient = appointment = {}
|
||||
|
||||
fallback_diagnosis_id = first_value(
|
||||
fallback_record,
|
||||
"diagnosis_id",
|
||||
"id",
|
||||
default=None,
|
||||
)
|
||||
fallback_patient_id = first_value(
|
||||
fallback_record,
|
||||
"source_patient_id",
|
||||
"patient_id",
|
||||
default=None,
|
||||
)
|
||||
if not _video_identity_matches(
|
||||
diagnosis_id,
|
||||
fallback_diagnosis_id,
|
||||
) or not _video_identity_matches(patient_id, fallback_patient_id):
|
||||
fallback_record = {}
|
||||
sources = (diagnosis, patient, appointment, fallback_record)
|
||||
|
||||
def pick(*keys: str, limit: int = 2000) -> str:
|
||||
for source in sources:
|
||||
value = first_value(source, *keys, default=None)
|
||||
text = _video_case_text(value, limit=limit)
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
raw_gender = next(
|
||||
(
|
||||
first_value(source, "gender_desc", "gender", default=None)
|
||||
for source in sources
|
||||
if first_value(source, "gender_desc", "gender", default=None) not in (None, "")
|
||||
),
|
||||
None,
|
||||
)
|
||||
return {
|
||||
"diagnosisId": _video_case_text(diagnosis_id, limit=80),
|
||||
"name": pick("patient_name", "name", limit=120)
|
||||
or _video_case_text(patient_name, limit=120)
|
||||
or "患者",
|
||||
"gender": "" if raw_gender in (None, "") else gender_text(raw_gender),
|
||||
"age": pick("age", limit=20),
|
||||
"height": pick("height", limit=20),
|
||||
"weight": pick("weight", limit=20),
|
||||
"diagnosisDate": pick("diagnosis_date", "diagnosis_date_text", limit=80),
|
||||
"appointmentDate": pick(
|
||||
"appointment_date",
|
||||
"latest_appointment_date",
|
||||
limit=80,
|
||||
),
|
||||
"clinicalDiagnosis": pick(
|
||||
"clinical_diagnosis",
|
||||
"diagnosis_name",
|
||||
"disease_name",
|
||||
),
|
||||
"chiefComplaint": pick("chief_complaint", "complaint"),
|
||||
"presentIllness": pick("present_illness", "present_illness_history", "symptoms"),
|
||||
"pastHistory": pick("past_history_text", "past_history_desc", "past_history"),
|
||||
"allergyHistory": pick(
|
||||
"allergy_history_text",
|
||||
"allergy_history_desc",
|
||||
"allergy_history",
|
||||
),
|
||||
"personalHistory": pick(
|
||||
"personal_history_text",
|
||||
"personal_history_desc",
|
||||
"personal_history",
|
||||
),
|
||||
"familyHistory": pick(
|
||||
"family_history_text",
|
||||
"family_history_desc",
|
||||
"family_history",
|
||||
),
|
||||
"currentMedication": pick(
|
||||
"current_medications",
|
||||
"current_medicine",
|
||||
"current_medication",
|
||||
),
|
||||
"tongue": pick("tongue", "tongue_coating"),
|
||||
"pulse": pick("pulse", "pulse_condition"),
|
||||
"prescriptionOpinion": pick("prescription_opinion", "prescription_advice"),
|
||||
"remark": pick("remark"),
|
||||
}
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _video_case_text(value: Any, *, limit: int = 2000) -> str:
|
||||
"""Render a bounded, JSON-safe clinical value for the trusted call rail."""
|
||||
|
||||
if value in (None, "", [], {}):
|
||||
return ""
|
||||
if isinstance(value, Mapping):
|
||||
parts = [
|
||||
f"{key}:{_video_case_text(item, limit=limit)}"
|
||||
for key, item in value.items()
|
||||
if item not in (None, "", [], {})
|
||||
]
|
||||
return ";".join(part for part in parts if not part.endswith(":"))[:limit]
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return "、".join(
|
||||
part
|
||||
for item in value
|
||||
if (part := _video_case_text(item, limit=limit))
|
||||
)[:limit]
|
||||
return str(value).strip()[:limit]
|
||||
|
||||
|
||||
def _video_identity(value: Any) -> str:
|
||||
if value in (None, "") or isinstance(value, bool):
|
||||
return ""
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return ""
|
||||
with suppress(ValueError, TypeError):
|
||||
return str(int(text))
|
||||
return text
|
||||
|
||||
|
||||
def _video_identity_matches(expected: Any, actual: Any) -> bool:
|
||||
normalized_actual = _video_identity(actual)
|
||||
return not normalized_actual or normalized_actual == _video_identity(expected)
|
||||
|
||||
|
||||
def _build_video_patient_case(
|
||||
detail: Any,
|
||||
fallback_record: Any,
|
||||
*,
|
||||
diagnosis_id: Any,
|
||||
patient_id: Any,
|
||||
patient_name: str,
|
||||
) -> dict[str, str]:
|
||||
"""Reduce the readonly diagnosis aggregate to the fields needed in-call."""
|
||||
|
||||
if isinstance(detail, Mapping) and not get_value(detail, "diagnosis", None):
|
||||
nested = get_value(detail, "data", None)
|
||||
if isinstance(nested, Mapping):
|
||||
detail = nested
|
||||
diagnosis = get_value(detail, "diagnosis", None)
|
||||
if not diagnosis and isinstance(detail, Mapping):
|
||||
diagnosis = detail
|
||||
diagnosis = diagnosis or {}
|
||||
patient = get_value(detail, "patient", None) or {}
|
||||
appointment = get_value(detail, "appointment", None) or {}
|
||||
|
||||
detail_diagnosis_id = first_value(diagnosis, "id", "diagnosis_id", default=None)
|
||||
detail_patient_id = first_value(
|
||||
diagnosis,
|
||||
"source_patient_id",
|
||||
default=first_value(
|
||||
patient,
|
||||
"source_patient_id",
|
||||
default=None,
|
||||
),
|
||||
)
|
||||
if not _video_identity_matches(
|
||||
diagnosis_id,
|
||||
detail_diagnosis_id,
|
||||
) or not _video_identity_matches(patient_id, detail_patient_id):
|
||||
detail = diagnosis = patient = appointment = {}
|
||||
|
||||
fallback_diagnosis_id = first_value(
|
||||
fallback_record,
|
||||
"diagnosis_id",
|
||||
"id",
|
||||
default=None,
|
||||
)
|
||||
fallback_patient_id = first_value(
|
||||
fallback_record,
|
||||
"source_patient_id",
|
||||
"patient_id",
|
||||
default=None,
|
||||
)
|
||||
if not _video_identity_matches(
|
||||
diagnosis_id,
|
||||
fallback_diagnosis_id,
|
||||
) or not _video_identity_matches(patient_id, fallback_patient_id):
|
||||
fallback_record = {}
|
||||
sources = (diagnosis, patient, appointment, fallback_record)
|
||||
|
||||
def pick(*keys: str, limit: int = 2000) -> str:
|
||||
for source in sources:
|
||||
value = first_value(source, *keys, default=None)
|
||||
text = _video_case_text(value, limit=limit)
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
raw_gender = next(
|
||||
(
|
||||
first_value(source, "gender_desc", "gender", default=None)
|
||||
for source in sources
|
||||
if first_value(source, "gender_desc", "gender", default=None) not in (None, "")
|
||||
),
|
||||
None,
|
||||
)
|
||||
return {
|
||||
"diagnosisId": _video_case_text(diagnosis_id, limit=80),
|
||||
"name": pick("patient_name", "name", limit=120)
|
||||
or _video_case_text(patient_name, limit=120)
|
||||
or "患者",
|
||||
"gender": "" if raw_gender in (None, "") else gender_text(raw_gender),
|
||||
"age": pick("age", limit=20),
|
||||
"height": pick("height", limit=20),
|
||||
"weight": pick("weight", limit=20),
|
||||
"diagnosisDate": pick("diagnosis_date", "diagnosis_date_text", limit=80),
|
||||
"appointmentDate": pick(
|
||||
"appointment_date",
|
||||
"latest_appointment_date",
|
||||
limit=80,
|
||||
),
|
||||
"clinicalDiagnosis": pick(
|
||||
"clinical_diagnosis",
|
||||
"diagnosis_name",
|
||||
"disease_name",
|
||||
),
|
||||
"chiefComplaint": pick("chief_complaint", "complaint"),
|
||||
"presentIllness": pick("present_illness", "present_illness_history", "symptoms"),
|
||||
"pastHistory": pick("past_history_text", "past_history_desc", "past_history"),
|
||||
"allergyHistory": pick(
|
||||
"allergy_history_text",
|
||||
"allergy_history_desc",
|
||||
"allergy_history",
|
||||
),
|
||||
"personalHistory": pick(
|
||||
"personal_history_text",
|
||||
"personal_history_desc",
|
||||
"personal_history",
|
||||
),
|
||||
"familyHistory": pick(
|
||||
"family_history_text",
|
||||
"family_history_desc",
|
||||
"family_history",
|
||||
),
|
||||
"currentMedication": pick(
|
||||
"current_medications",
|
||||
"current_medicine",
|
||||
"current_medication",
|
||||
),
|
||||
"tongue": pick("tongue", "tongue_coating"),
|
||||
"pulse": pick("pulse", "pulse_condition"),
|
||||
"prescriptionOpinion": pick("prescription_opinion", "prescription_advice"),
|
||||
"remark": pick("remark"),
|
||||
}
|
||||
|
||||
|
||||
class _ChineseQtTranslator(QTranslator):
|
||||
@@ -313,7 +313,7 @@ class DemoVideoDialog(QDialog):
|
||||
"background:#FFFFFF;color:#3F4E75;border:1px solid #E6EAF5;font-weight:600;}"
|
||||
"QPushButton:hover{color:#4451E2;background:#F0F2FF;border-color:#5761F4;}"
|
||||
"QPushButton:checked{color:#FFFFFF;background:#5761F4;border-color:#5761F4;}"
|
||||
"QPushButton#Hangup{color:#FFFFFF;background:#F15B67;border-color:#F15B67;}"
|
||||
"QPushButton#Hangup{color:#FFFFFF;background:#C23D4E;border-color:#C23D4E;}"
|
||||
"QPushButton#Hangup:hover{background:#D94857;border-color:#D94857;}"
|
||||
)
|
||||
|
||||
@@ -326,7 +326,7 @@ class DemoVideoDialog(QDialog):
|
||||
header.addWidget(title)
|
||||
header.addStretch(1)
|
||||
demo = QLabel("● 演示模式 · 未连接腾讯云")
|
||||
demo.setStyleSheet("color:#7886AA;font-size:12px;")
|
||||
demo.setStyleSheet("color:#707584;font-size:12px;")
|
||||
header.addWidget(demo)
|
||||
self.duration_label = QLabel("00:00")
|
||||
self.duration_label.setStyleSheet("font-weight:700;")
|
||||
@@ -400,22 +400,22 @@ class ApplicationController(QObject):
|
||||
"""Own windows, repositories and the authenticated application lifecycle."""
|
||||
|
||||
def __init__(self, application: QApplication, config: AppConfig) -> None:
|
||||
super().__init__()
|
||||
self.application = application
|
||||
self.config = config
|
||||
self.debug_mode = bool(getattr(config, "debug_mode", False))
|
||||
self.token_store = TokenStore(config.config_dir / "credentials.json")
|
||||
self.demo_repository = DemoDoctorRepository() if self.debug_mode else None
|
||||
super().__init__()
|
||||
self.application = application
|
||||
self.config = config
|
||||
self.debug_mode = bool(getattr(config, "debug_mode", False))
|
||||
self.token_store = TokenStore(config.config_dir / "credentials.json")
|
||||
self.demo_repository = DemoDoctorRepository() if self.debug_mode else None
|
||||
self.remote_repository: RemoteDoctorRepository | None = None
|
||||
self.login_window: LoginWindow | None = None
|
||||
self.shell_window: ShellWindow | None = None
|
||||
self.current_repository: Any = None
|
||||
self.current_demo_mode = self.debug_mode and config.demo_mode
|
||||
self.video_calls: dict[str, Any] = {}
|
||||
self.video_pending: dict[str, object] = {}
|
||||
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
|
||||
self._video_preview_state: dict[str, Any] | None = None
|
||||
self._video_preview_generation = 0
|
||||
self.current_demo_mode = self.debug_mode and config.demo_mode
|
||||
self.video_calls: dict[str, Any] = {}
|
||||
self.video_pending: dict[str, object] = {}
|
||||
self.demo_video_dialogs: dict[str, DemoVideoDialog] = {}
|
||||
self._video_preview_state: dict[str, Any] | None = None
|
||||
self._video_preview_generation = 0
|
||||
self._restore_generation = 0
|
||||
self._restore_in_progress = False
|
||||
self._restore_worker: Any = None
|
||||
@@ -506,11 +506,11 @@ class ApplicationController(QObject):
|
||||
if self.login_window is not None:
|
||||
self.login_window.config = self.config
|
||||
|
||||
def _on_demo_mode_changed(self, enabled: bool) -> None:
|
||||
allowed = self.debug_mode and enabled
|
||||
self.current_demo_mode = allowed
|
||||
if allowed:
|
||||
self._cancel_session_restore()
|
||||
def _on_demo_mode_changed(self, enabled: bool) -> None:
|
||||
allowed = self.debug_mode and enabled
|
||||
self.current_demo_mode = allowed
|
||||
if allowed:
|
||||
self._cancel_session_restore()
|
||||
|
||||
def _rebuild_remote_repository(self) -> None:
|
||||
old = self.remote_repository
|
||||
@@ -670,8 +670,8 @@ class ApplicationController(QObject):
|
||||
self._login_guard_error("该账号需要先绑定企业微信,请在管理后台完成绑定后重新登录。")
|
||||
return
|
||||
|
||||
demo_mode = self.debug_mode and bool(payload.get("demo_mode"))
|
||||
payload["demo_mode"] = demo_mode
|
||||
demo_mode = self.debug_mode and bool(payload.get("demo_mode"))
|
||||
payload["demo_mode"] = demo_mode
|
||||
if not demo_mode and not session.menu:
|
||||
with suppress(Exception):
|
||||
repository.logout()
|
||||
@@ -715,17 +715,17 @@ class ApplicationController(QObject):
|
||||
self._logout(message="登录状态已失效,请重新登录。")
|
||||
return True
|
||||
|
||||
def _logout(self, *, message: str = "") -> None:
|
||||
"""Clear authenticated resources and return to the login window."""
|
||||
|
||||
self._restore_video_preview(activate=False)
|
||||
calls = tuple(self.video_calls.values())
|
||||
def _logout(self, *, message: str = "") -> None:
|
||||
"""Clear authenticated resources and return to the login window."""
|
||||
|
||||
self._restore_video_preview(activate=False)
|
||||
calls = tuple(self.video_calls.values())
|
||||
for call in calls:
|
||||
with suppress(Exception):
|
||||
call.close()
|
||||
self._wait_for_video_lifecycle(calls, timeout=1.25)
|
||||
self.video_calls.clear()
|
||||
self.video_pending.clear()
|
||||
self.video_calls.clear()
|
||||
self.video_pending.clear()
|
||||
for dialog in self.demo_video_dialogs.values():
|
||||
dialog.close()
|
||||
self.demo_video_dialogs.clear()
|
||||
@@ -748,8 +748,8 @@ class ApplicationController(QObject):
|
||||
patient_id = payload.get("patient_id")
|
||||
diagnosis_id = payload.get("diagnosis_id")
|
||||
patient_name = str(payload.get("patient_name") or "患者")
|
||||
open_im = str(payload.get("mode") or "video").lower() == "im"
|
||||
fallback_record = payload.get("record")
|
||||
open_im = str(payload.get("mode") or "video").lower() == "im"
|
||||
fallback_record = payload.get("record")
|
||||
if patient_id in (None, "") or diagnosis_id in (None, ""):
|
||||
show_toast(parent, "患者或诊单信息不完整,无法发起视频。", "danger", 4200)
|
||||
return
|
||||
@@ -803,49 +803,49 @@ class ApplicationController(QObject):
|
||||
marker = object()
|
||||
self.video_pending[call_key] = marker
|
||||
|
||||
def get_video_context() -> tuple[Any, dict[str, str]]:
|
||||
ticket = repository.get_call_ticket(
|
||||
patient_id=int(patient_id),
|
||||
diagnosis_id=int(diagnosis_id),
|
||||
)
|
||||
detail: Any = {}
|
||||
detail_loader = getattr(repository, "patient_detail", None)
|
||||
if callable(detail_loader):
|
||||
try:
|
||||
detail = detail_loader(int(diagnosis_id))
|
||||
except Exception as error:
|
||||
LOGGER.warning(
|
||||
"patient detail could not be loaded for video call",
|
||||
extra={
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"error_type": type(error).__name__,
|
||||
},
|
||||
)
|
||||
patient_case = _build_video_patient_case(
|
||||
detail,
|
||||
fallback_record,
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
patient_name=patient_name,
|
||||
)
|
||||
return ticket, patient_case
|
||||
def get_video_context() -> tuple[Any, dict[str, str]]:
|
||||
ticket = repository.get_call_ticket(
|
||||
patient_id=int(patient_id),
|
||||
diagnosis_id=int(diagnosis_id),
|
||||
)
|
||||
detail: Any = {}
|
||||
detail_loader = getattr(repository, "patient_detail", None)
|
||||
if callable(detail_loader):
|
||||
try:
|
||||
detail = detail_loader(int(diagnosis_id))
|
||||
except Exception as error:
|
||||
LOGGER.warning(
|
||||
"patient detail could not be loaded for video call",
|
||||
extra={
|
||||
"diagnosis_id": diagnosis_id,
|
||||
"error_type": type(error).__name__,
|
||||
},
|
||||
)
|
||||
patient_case = _build_video_patient_case(
|
||||
detail,
|
||||
fallback_record,
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
patient_name=patient_name,
|
||||
)
|
||||
return ticket, patient_case
|
||||
|
||||
def request_ticket() -> None:
|
||||
if self.video_pending.get(call_key) is not marker:
|
||||
return
|
||||
run_async(
|
||||
get_video_context,
|
||||
on_success=lambda context: self._launch_video(
|
||||
context[0],
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
repository=repository,
|
||||
run_async(
|
||||
get_video_context,
|
||||
on_success=lambda context: self._launch_video(
|
||||
context[0],
|
||||
diagnosis_id=diagnosis_id,
|
||||
patient_id=patient_id,
|
||||
repository=repository,
|
||||
call_key=call_key,
|
||||
marker=marker,
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
patient_case=context[1],
|
||||
),
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
patient_case=context[1],
|
||||
),
|
||||
on_error=lambda error: self._video_ticket_error(
|
||||
call_key,
|
||||
marker,
|
||||
@@ -888,9 +888,9 @@ class ApplicationController(QObject):
|
||||
repository: Any,
|
||||
call_key: str,
|
||||
marker: object,
|
||||
open_im: bool = False,
|
||||
patient_name: str = "患者",
|
||||
patient_case: Mapping[str, Any] | None = None,
|
||||
open_im: bool = False,
|
||||
patient_name: str = "患者",
|
||||
patient_case: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
if self.video_pending.get(call_key) is not marker:
|
||||
return
|
||||
@@ -916,13 +916,13 @@ class ApplicationController(QObject):
|
||||
local_dist=video_dist_path(),
|
||||
remote_url=self.config.video_web_url or None,
|
||||
logger=logging.getLogger("doctor_workstation.video"),
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
patient_case=patient_case,
|
||||
on_open_diagnosis=lambda current_id=diagnosis_id: (
|
||||
self._open_video_diagnosis(current_id)
|
||||
),
|
||||
)
|
||||
open_im=open_im,
|
||||
patient_name=patient_name,
|
||||
patient_case=patient_case,
|
||||
on_open_diagnosis=lambda current_id=diagnosis_id: (
|
||||
self._open_video_diagnosis(current_id)
|
||||
),
|
||||
)
|
||||
except Exception as error:
|
||||
LOGGER.exception("video call could not be launched")
|
||||
show_toast(
|
||||
@@ -931,123 +931,123 @@ class ApplicationController(QObject):
|
||||
"danger",
|
||||
5600,
|
||||
)
|
||||
return
|
||||
self.video_calls[call_key] = call
|
||||
return
|
||||
self.video_calls[call_key] = call
|
||||
qt_window = getattr(call, "qt_window", None)
|
||||
if qt_window is not None:
|
||||
qt_window.destroyed.connect(
|
||||
lambda _obj=None, key=call_key, expected=call: self._release_video_call(
|
||||
key,
|
||||
expected,
|
||||
)
|
||||
)
|
||||
|
||||
def _open_video_diagnosis(self, diagnosis_id: Any) -> None:
|
||||
"""Open the diagnosis while keeping its live video visible as a preview."""
|
||||
|
||||
shell = self.shell_window
|
||||
if shell is None or self.current_repository is None:
|
||||
return
|
||||
dialog = shell.open_diagnosis_by_id(diagnosis_id, modeless=True)
|
||||
if dialog is None:
|
||||
return
|
||||
call = self.video_calls.get(str(diagnosis_id))
|
||||
video_window = getattr(call, "qt_window", None)
|
||||
if video_window is not None:
|
||||
self._show_video_preview(video_window, dialog)
|
||||
|
||||
def _show_video_preview(self, video_window: Any, dialog: QDialog) -> None:
|
||||
"""Pin a compact call window above the modeless diagnosis drawer."""
|
||||
|
||||
current = self._video_preview_state
|
||||
if current is not None and current.get("window") is not video_window:
|
||||
self._restore_video_preview(activate=False)
|
||||
|
||||
self._video_preview_generation += 1
|
||||
generation = self._video_preview_generation
|
||||
if current is None or current.get("window") is not video_window:
|
||||
try:
|
||||
state = {
|
||||
"window": video_window,
|
||||
"geometry": video_window.geometry(),
|
||||
"minimum_size": video_window.minimumSize(),
|
||||
"maximized": video_window.isMaximized(),
|
||||
"full_screen": video_window.isFullScreen(),
|
||||
"stays_on_top": bool(
|
||||
video_window.windowFlags()
|
||||
& Qt.WindowType.WindowStaysOnTopHint
|
||||
),
|
||||
}
|
||||
screen = video_window.screen() or QGuiApplication.primaryScreen()
|
||||
available = screen.availableGeometry()
|
||||
preview_width = min(540, max(460, round(available.width() * 0.29)))
|
||||
preview_height = min(380, max(320, round(preview_width * 0.66)))
|
||||
margin = 18
|
||||
|
||||
video_window.showNormal()
|
||||
video_window.setMinimumSize(440, 300)
|
||||
video_window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
|
||||
video_window.resize(preview_width, preview_height)
|
||||
video_window.move(
|
||||
available.x() + available.width() - preview_width - margin,
|
||||
available.y() + margin,
|
||||
)
|
||||
video_window.show()
|
||||
self._video_preview_state = state
|
||||
except RuntimeError:
|
||||
self._video_preview_state = None
|
||||
return
|
||||
|
||||
dialog.finished.connect(
|
||||
lambda _result, expected=generation: self._restore_video_preview(expected)
|
||||
)
|
||||
try:
|
||||
video_window.raise_()
|
||||
dialog.raise_()
|
||||
dialog.activateWindow()
|
||||
except RuntimeError:
|
||||
self._video_preview_state = None
|
||||
|
||||
def _restore_video_preview(
|
||||
self,
|
||||
generation: int | None = None,
|
||||
*,
|
||||
activate: bool = True,
|
||||
) -> None:
|
||||
if generation is not None and generation != self._video_preview_generation:
|
||||
return
|
||||
state = self._video_preview_state
|
||||
if state is None:
|
||||
return
|
||||
self._video_preview_state = None
|
||||
self._video_preview_generation += 1
|
||||
window = state.get("window")
|
||||
try:
|
||||
window.setWindowFlag(
|
||||
Qt.WindowType.WindowStaysOnTopHint,
|
||||
bool(state.get("stays_on_top")),
|
||||
)
|
||||
window.setMinimumSize(state["minimum_size"])
|
||||
window.setGeometry(state["geometry"])
|
||||
if state.get("full_screen"):
|
||||
window.showFullScreen()
|
||||
elif state.get("maximized"):
|
||||
window.showMaximized()
|
||||
else:
|
||||
window.showNormal()
|
||||
window.raise_()
|
||||
if activate:
|
||||
window.activateWindow()
|
||||
except (AttributeError, RuntimeError):
|
||||
return
|
||||
|
||||
def _release_video_call(self, call_key: str, call: Any) -> None:
|
||||
preview = self._video_preview_state
|
||||
if preview is not None and preview.get("window") is getattr(call, "qt_window", None):
|
||||
self._video_preview_state = None
|
||||
self._video_preview_generation += 1
|
||||
if self.video_calls.get(call_key) is call:
|
||||
self.video_calls.pop(call_key, None)
|
||||
)
|
||||
)
|
||||
|
||||
def _open_video_diagnosis(self, diagnosis_id: Any) -> None:
|
||||
"""Open the diagnosis while keeping its live video visible as a preview."""
|
||||
|
||||
shell = self.shell_window
|
||||
if shell is None or self.current_repository is None:
|
||||
return
|
||||
dialog = shell.open_diagnosis_by_id(diagnosis_id, modeless=True)
|
||||
if dialog is None:
|
||||
return
|
||||
call = self.video_calls.get(str(diagnosis_id))
|
||||
video_window = getattr(call, "qt_window", None)
|
||||
if video_window is not None:
|
||||
self._show_video_preview(video_window, dialog)
|
||||
|
||||
def _show_video_preview(self, video_window: Any, dialog: QDialog) -> None:
|
||||
"""Pin a compact call window above the modeless diagnosis drawer."""
|
||||
|
||||
current = self._video_preview_state
|
||||
if current is not None and current.get("window") is not video_window:
|
||||
self._restore_video_preview(activate=False)
|
||||
|
||||
self._video_preview_generation += 1
|
||||
generation = self._video_preview_generation
|
||||
if current is None or current.get("window") is not video_window:
|
||||
try:
|
||||
state = {
|
||||
"window": video_window,
|
||||
"geometry": video_window.geometry(),
|
||||
"minimum_size": video_window.minimumSize(),
|
||||
"maximized": video_window.isMaximized(),
|
||||
"full_screen": video_window.isFullScreen(),
|
||||
"stays_on_top": bool(
|
||||
video_window.windowFlags()
|
||||
& Qt.WindowType.WindowStaysOnTopHint
|
||||
),
|
||||
}
|
||||
screen = video_window.screen() or QGuiApplication.primaryScreen()
|
||||
available = screen.availableGeometry()
|
||||
preview_width = min(540, max(460, round(available.width() * 0.29)))
|
||||
preview_height = min(380, max(320, round(preview_width * 0.66)))
|
||||
margin = 18
|
||||
|
||||
video_window.showNormal()
|
||||
video_window.setMinimumSize(440, 300)
|
||||
video_window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
|
||||
video_window.resize(preview_width, preview_height)
|
||||
video_window.move(
|
||||
available.x() + available.width() - preview_width - margin,
|
||||
available.y() + margin,
|
||||
)
|
||||
video_window.show()
|
||||
self._video_preview_state = state
|
||||
except RuntimeError:
|
||||
self._video_preview_state = None
|
||||
return
|
||||
|
||||
dialog.finished.connect(
|
||||
lambda _result, expected=generation: self._restore_video_preview(expected)
|
||||
)
|
||||
try:
|
||||
video_window.raise_()
|
||||
dialog.raise_()
|
||||
dialog.activateWindow()
|
||||
except RuntimeError:
|
||||
self._video_preview_state = None
|
||||
|
||||
def _restore_video_preview(
|
||||
self,
|
||||
generation: int | None = None,
|
||||
*,
|
||||
activate: bool = True,
|
||||
) -> None:
|
||||
if generation is not None and generation != self._video_preview_generation:
|
||||
return
|
||||
state = self._video_preview_state
|
||||
if state is None:
|
||||
return
|
||||
self._video_preview_state = None
|
||||
self._video_preview_generation += 1
|
||||
window = state.get("window")
|
||||
try:
|
||||
window.setWindowFlag(
|
||||
Qt.WindowType.WindowStaysOnTopHint,
|
||||
bool(state.get("stays_on_top")),
|
||||
)
|
||||
window.setMinimumSize(state["minimum_size"])
|
||||
window.setGeometry(state["geometry"])
|
||||
if state.get("full_screen"):
|
||||
window.showFullScreen()
|
||||
elif state.get("maximized"):
|
||||
window.showMaximized()
|
||||
else:
|
||||
window.showNormal()
|
||||
window.raise_()
|
||||
if activate:
|
||||
window.activateWindow()
|
||||
except (AttributeError, RuntimeError):
|
||||
return
|
||||
|
||||
def _release_video_call(self, call_key: str, call: Any) -> None:
|
||||
preview = self._video_preview_state
|
||||
if preview is not None and preview.get("window") is getattr(call, "qt_window", None):
|
||||
self._video_preview_state = None
|
||||
self._video_preview_generation += 1
|
||||
if self.video_calls.get(call_key) is call:
|
||||
self.video_calls.pop(call_key, None)
|
||||
|
||||
def _forget_demo_dialog(self, call_key: str, dialog: DemoVideoDialog) -> None:
|
||||
if self.demo_video_dialogs.get(call_key) is dialog:
|
||||
@@ -1072,30 +1072,30 @@ class ApplicationController(QObject):
|
||||
LOGGER.warning("video lifecycle cleanup exceeded its bounded deadline")
|
||||
return complete
|
||||
|
||||
@staticmethod
|
||||
def _apply_window_icon(window: QWidget) -> None:
|
||||
icon_file = app_icon_path()
|
||||
if icon_file.exists():
|
||||
window.setWindowIcon(QIcon(str(icon_file)))
|
||||
|
||||
def request_quit(self) -> None:
|
||||
"""Queue a normal application exit so owned resources are released."""
|
||||
|
||||
if self._shutting_down:
|
||||
return
|
||||
QTimer.singleShot(0, self.application.quit)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
@staticmethod
|
||||
def _apply_window_icon(window: QWidget) -> None:
|
||||
icon_file = app_icon_path()
|
||||
if icon_file.exists():
|
||||
window.setWindowIcon(QIcon(str(icon_file)))
|
||||
|
||||
def request_quit(self) -> None:
|
||||
"""Queue a normal application exit so owned resources are released."""
|
||||
|
||||
if self._shutting_down:
|
||||
return
|
||||
QTimer.singleShot(0, self.application.quit)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Invalidate asynchronous restoration and release owned resources."""
|
||||
|
||||
if self._shutting_down:
|
||||
return
|
||||
self._shutting_down = True
|
||||
self._cancel_session_restore()
|
||||
self.app_updater.shutdown()
|
||||
set_authentication_expired_handler(None)
|
||||
self._restore_video_preview(activate=False)
|
||||
calls = tuple(self.video_calls.values())
|
||||
self._shutting_down = True
|
||||
self._cancel_session_restore()
|
||||
self.app_updater.shutdown()
|
||||
set_authentication_expired_handler(None)
|
||||
self._restore_video_preview(activate=False)
|
||||
calls = tuple(self.video_calls.values())
|
||||
for call in calls:
|
||||
with suppress(Exception):
|
||||
call.close()
|
||||
@@ -1167,7 +1167,7 @@ def _create_application(argv: list[str]) -> QApplication:
|
||||
application.setOrganizationName("ZhenYangTang")
|
||||
application.setOrganizationDomain("zhenyangtang.com")
|
||||
application.setQuitOnLastWindowClosed(True)
|
||||
icon_file = app_icon_path()
|
||||
icon_file = app_icon_path()
|
||||
if icon_file.exists():
|
||||
application.setWindowIcon(QIcon(str(icon_file)))
|
||||
apply_theme(application)
|
||||
|
||||
@@ -55,26 +55,25 @@ from .widgets import (
|
||||
APPOINTMENT_DRAWER_QSS = r"""
|
||||
QDialog#AppointmentDrawerOverlay {
|
||||
background-color: transparent;
|
||||
color: #111F46;
|
||||
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
||||
font-size: 13px;
|
||||
color: #1A1C1F;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerPanel {
|
||||
background-color: #FFFFFF;
|
||||
border-left: 1px solid #E6EAF5;
|
||||
border-left: 1px solid #EDEDEE;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerHeader {
|
||||
background-color: #FFFFFF;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #E6EAF5;
|
||||
border-bottom: 1px solid #EDEDEE;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QLabel#AppointmentDrawerTitle {
|
||||
color: #111F46;
|
||||
color: #1A1C1F;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose {
|
||||
@@ -86,14 +85,14 @@ QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose {
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
color: #7886AA;
|
||||
color: #606163;
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QToolButton#AppointmentDrawerClose:hover {
|
||||
color: #4451E2;
|
||||
background-color: #F0F2FF;
|
||||
color: #1A1C1F;
|
||||
background-color: #F0F0F0;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QScrollArea#AppointmentDrawerBody,
|
||||
@@ -109,13 +108,13 @@ QDialog#AppointmentDrawerOverlay QWidget#AppointmentDrawerBodyContent {
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QLabel[appointmentLabel="true"] {
|
||||
color: #3F4E75;
|
||||
color: #1A1C1F;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QLabel[appointmentMuted="true"] {
|
||||
color: #7886AA;
|
||||
color: #606163; font-size: 13px;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QComboBox,
|
||||
@@ -123,28 +122,28 @@ QDialog#AppointmentDrawerOverlay QLineEdit,
|
||||
QDialog#AppointmentDrawerOverlay QPlainTextEdit {
|
||||
min-height: 30px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid #E6EAF5;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 9px;
|
||||
background-color: #FFFFFF;
|
||||
color: #111F46;
|
||||
selection-background-color: #5761F4;
|
||||
selection-color: #FFFFFF;
|
||||
color: #1A1C1F;
|
||||
selection-background-color: #EEF1FA;
|
||||
selection-color: #1A1C1F; font-size: 14px;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPlainTextEdit {
|
||||
padding: 7px 11px;
|
||||
padding: 7px 11px; font-size: 14px;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QComboBox:hover,
|
||||
QDialog#AppointmentDrawerOverlay QLineEdit:hover,
|
||||
QDialog#AppointmentDrawerOverlay QPlainTextEdit:hover {
|
||||
border-color: #5761F4;
|
||||
border-color: #8B9AD9;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QComboBox:focus,
|
||||
QDialog#AppointmentDrawerOverlay QLineEdit:focus,
|
||||
QDialog#AppointmentDrawerOverlay QPlainTextEdit:focus {
|
||||
border: 2px solid #8D9BFF;
|
||||
border: 2px solid #8B9AD9;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QComboBox::drop-down {
|
||||
@@ -154,81 +153,81 @@ QDialog#AppointmentDrawerOverlay QComboBox::drop-down {
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QComboBox QAbstractItemView {
|
||||
background-color: #FFFFFF;
|
||||
color: #111F46;
|
||||
border: 1px solid #E6EAF5;
|
||||
selection-background-color: #5761F4;
|
||||
selection-color: #FFFFFF;
|
||||
outline: 0;
|
||||
color: #1A1C1F;
|
||||
border: 1px solid #EDEDEE;
|
||||
selection-background-color: #EEF1FA;
|
||||
selection-color: #1A1C1F;
|
||||
outline: 0; font-size: 14px;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QRadioButton {
|
||||
min-height: 24px;
|
||||
spacing: 8px;
|
||||
color: #3F4E75;
|
||||
color: #1A1C1F;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QRadioButton::indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid #E6EAF5;
|
||||
border: 1px solid #EDEDEE;
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QRadioButton::indicator:hover {
|
||||
border-color: #5761F4;
|
||||
border-color: #4156C4;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QRadioButton::indicator:checked {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border: 5px solid #5761F4;
|
||||
border: 5px solid #4F63D9;
|
||||
border-radius: 7px;
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QRadioButton:focus {
|
||||
color: #4451E2;
|
||||
color: #4F63D9;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"] {
|
||||
min-height: 38px;
|
||||
max-height: 38px;
|
||||
padding: 0;
|
||||
border: 1px solid #E6EAF5;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 8px;
|
||||
background-color: #FFFFFF;
|
||||
color: #3F4E75;
|
||||
color: #1A1C1F;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:hover {
|
||||
color: #4451E2;
|
||||
border-color: #5761F4;
|
||||
background-color: #F0F2FF;
|
||||
color: #4156C4;
|
||||
border-color: #4156C4;
|
||||
background-color: #EEF1FA;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:focus {
|
||||
border-color: #8D9BFF;
|
||||
border-color: #8B9AD9;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentDate="true"]:checked {
|
||||
color: #FFFFFF;
|
||||
border-color: #5761F4;
|
||||
background-color: #5761F4;
|
||||
border-color: #4F63D9;
|
||||
background-color: #4F63D9;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentSlotsPanel {
|
||||
background-color: #F7F9FE;
|
||||
background-color: #F7F7F7;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QLabel#AppointmentSlotsTitle {
|
||||
color: #111F46;
|
||||
color: #1A1C1F;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots {
|
||||
@@ -238,87 +237,83 @@ QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots {
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background-color: transparent;
|
||||
color: #4451E2;
|
||||
color: #4F63D9;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton#AppointmentRefreshSlots:hover {
|
||||
background-color: #F0F2FF;
|
||||
background-color: #EEF1FA;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] {
|
||||
min-width: 110px;
|
||||
min-height: 70px;
|
||||
padding: 0 8px;
|
||||
border: 2px solid #E6EAF5;
|
||||
border: 2px solid #EDEDEE;
|
||||
border-radius: 8px;
|
||||
background-color: #FFFFFF;
|
||||
color: #111F46;
|
||||
color: #1A1C1F;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime {
|
||||
color: #111F46;
|
||||
color: #1A1C1F;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus {
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
background-color: #F4F4F5;
|
||||
color: #7886AA;
|
||||
font-size: 12px;
|
||||
background-color: #F7F7F7;
|
||||
color: #606163;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"][availability="available"] QLabel#AppointmentSlotStatus {
|
||||
color: #17A77D;
|
||||
color: #287B65;
|
||||
background-color: #EAF9F3;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:hover:enabled {
|
||||
color: #4451E2;
|
||||
border-color: #5761F4;
|
||||
background-color: #F0F2FF;
|
||||
color: #4156C4;
|
||||
border-color: #4156C4;
|
||||
background-color: #EEF1FA;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:focus:enabled {
|
||||
border-color: #8D9BFF;
|
||||
border-color: #8B9AD9;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked {
|
||||
color: #FFFFFF;
|
||||
border-color: #5761F4;
|
||||
background: qlineargradient(
|
||||
x1:0, y1:0, x2:1, y2:1,
|
||||
stop:0 #5761F4,
|
||||
stop:1 #7769F7
|
||||
);
|
||||
border-color: #4F63D9;
|
||||
background: #4F63D9;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked QLabel#AppointmentSlotTime,
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked QLabel#AppointmentSlotStatus {
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime[slotSelected="true"],
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus[slotSelected="true"] {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:checked QLabel#AppointmentSlotStatus {
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus[slotSelected="true"] {
|
||||
background-color: rgba(255, 255, 255, 46);
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled {
|
||||
color: #A4ADC3;
|
||||
border-color: #E6EAF5;
|
||||
background-color: #F0F2F8;
|
||||
color: #8E8F90;
|
||||
border-color: #EDEDEE;
|
||||
background-color: #F7F7F7;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotTime,
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotStatus {
|
||||
color: #A4ADC3;
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotTime:disabled,
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus:disabled {
|
||||
color: #8E8F90;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"]:disabled QLabel#AppointmentSlotStatus {
|
||||
background-color: #F0F2F8;
|
||||
QDialog#AppointmentDrawerOverlay QPushButton[appointmentSlot="true"] QLabel#AppointmentSlotStatus:disabled {
|
||||
background-color: #F7F7F7;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QWidget#AppointmentInlineEmpty {
|
||||
@@ -326,13 +321,13 @@ QDialog#AppointmentDrawerOverlay QWidget#AppointmentInlineEmpty {
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QLabel#AppointmentEmptyText {
|
||||
color: #7886AA;
|
||||
color: #606163;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] {
|
||||
background-color: #F0F4FF;
|
||||
border: 1px solid #DDE5FF;
|
||||
background-color: #EEF1FA;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
@@ -355,65 +350,65 @@ QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] {
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="info"] QLabel {
|
||||
color: #4D69ED;
|
||||
color: #4F63D9;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="warning"] QLabel {
|
||||
color: #D38625;
|
||||
color: #A9691D;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="danger"] QLabel {
|
||||
color: #F15B67;
|
||||
color: #BE4B58;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentAlert[kind="success"] QLabel {
|
||||
color: #17A77D;
|
||||
color: #287B65;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter {
|
||||
background-color: #FFFFFF;
|
||||
border: 0;
|
||||
border-top: 1px solid #E6EAF5;
|
||||
border-top: 1px solid #EDEDEE;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton {
|
||||
min-height: 30px;
|
||||
max-height: 30px;
|
||||
padding: 0 15px;
|
||||
border: 1px solid #E6EAF5;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 9px;
|
||||
background-color: #FFFFFF;
|
||||
color: #3F4E75;
|
||||
color: #1A1C1F;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:hover {
|
||||
color: #4451E2;
|
||||
border-color: #5761F4;
|
||||
background-color: #F0F2FF;
|
||||
color: #4156C4;
|
||||
border-color: #4156C4;
|
||||
background-color: #EEF1FA;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:focus {
|
||||
border-color: #8D9BFF;
|
||||
border-color: #8B9AD9;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"] {
|
||||
color: #FFFFFF;
|
||||
border-color: #5761F4;
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #5761F4, stop:1 #7769F7);
|
||||
border-color: #4F63D9;
|
||||
background: #4F63D9;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton[primary="true"]:hover {
|
||||
color: #FFFFFF;
|
||||
border-color: #4C57E9;
|
||||
background-color: #4C57E9;
|
||||
border-color: #4156C4;
|
||||
background-color: #4156C4;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentDrawerFooter QPushButton:disabled {
|
||||
color: #FFFFFF;
|
||||
border-color: #E6EAF5;
|
||||
background-color: #A4ADC3;
|
||||
border-color: #EDEDEE;
|
||||
background-color: #8E8F90;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay {
|
||||
@@ -422,7 +417,7 @@ QDialog#AppointmentDrawerOverlay QFrame#AppointmentLoadingOverlay {
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QLabel#AppointmentLoadingText {
|
||||
color: #7886AA;
|
||||
color: #606163;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -436,11 +431,11 @@ QDialog#AppointmentDrawerOverlay QScrollBar:vertical {
|
||||
QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical {
|
||||
min-height: 30px;
|
||||
border-radius: 3px;
|
||||
background-color: #E6EAF5;
|
||||
background-color: #EDEDEE;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QScrollBar::handle:vertical:hover {
|
||||
background-color: #8D9BFF;
|
||||
background-color: #E4E4E5;
|
||||
}
|
||||
|
||||
QDialog#AppointmentDrawerOverlay QScrollBar::add-line:vertical,
|
||||
@@ -722,15 +717,15 @@ class _EmptyIllustration(QWidget):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#EEF2F8"))
|
||||
painter.setBrush(QColor("#F7F7F7"))
|
||||
painter.drawEllipse(QRect(9, 48, 62, 8))
|
||||
|
||||
painter.setPen(QPen(QColor("#D8DEEA"), 1))
|
||||
painter.setPen(QPen(QColor("#EDEDEE"), 1))
|
||||
painter.setBrush(QColor("#FFFFFF"))
|
||||
painter.drawRoundedRect(QRect(22, 21, 36, 27), 4, 4)
|
||||
painter.setBrush(QColor("#E9EDFF"))
|
||||
painter.setBrush(QColor("#F0F0F0"))
|
||||
painter.drawRoundedRect(QRect(18, 15, 44, 12), 4, 4)
|
||||
painter.setPen(QPen(QColor("#667085"), 2))
|
||||
painter.setPen(QPen(QColor("#606163"), 2))
|
||||
painter.drawLine(30, 35, 50, 35)
|
||||
painter.drawLine(34, 41, 46, 41)
|
||||
painter.end()
|
||||
@@ -751,7 +746,7 @@ class _HoverLiftButton(QPushButton):
|
||||
shadow = QGraphicsDropShadowEffect(self)
|
||||
shadow.setBlurRadius(12)
|
||||
shadow.setOffset(0, 4)
|
||||
shadow.setColor(QColor(102, 117, 245, 72))
|
||||
shadow.setColor(QColor(26, 28, 31, 72))
|
||||
self.setGraphicsEffect(shadow)
|
||||
self._lifted = True
|
||||
super().enterEvent(event)
|
||||
@@ -784,6 +779,15 @@ class _SlotCard(_HoverLiftButton):
|
||||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.status_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||
layout.addWidget(self.status_label)
|
||||
self.toggled.connect(self._sync_label_selection)
|
||||
|
||||
def _sync_label_selection(self, checked: bool) -> None:
|
||||
# Qt does not reliably resolve ancestor pseudo states for child labels.
|
||||
for label in (self.time_label, self.status_label):
|
||||
label.setProperty("slotSelected", checked)
|
||||
label.style().unpolish(label)
|
||||
label.style().polish(label)
|
||||
label.update()
|
||||
|
||||
|
||||
class AppointmentDrawer(QDialog):
|
||||
@@ -1705,7 +1709,7 @@ class AppointmentDrawer(QDialog):
|
||||
|
||||
def paintEvent(self, event: QPaintEvent) -> None: # noqa: N802 - Qt API
|
||||
painter = QPainter(self)
|
||||
painter.fillRect(self.rect(), QColor(8, 11, 20, 196))
|
||||
painter.fillRect(self.rect(), QColor(26, 28, 31, 196))
|
||||
painter.end()
|
||||
super().paintEvent(event)
|
||||
|
||||
|
||||
@@ -46,51 +46,51 @@ QFrame#ChatNotifyCard {
|
||||
border: 1px solid #BBF0CE;
|
||||
border-radius: 12px;
|
||||
}
|
||||
QFrame#ChatNotifyCard[kind="left"] { border-color: #D8DEEE; }
|
||||
QFrame#ChatNotifyCard[kind="complete"] { border-color: #C3D6FF; }
|
||||
QFrame#ChatNotifyCard[kind="left"] { border-color: #E4E4E5; }
|
||||
QFrame#ChatNotifyCard[kind="complete"] { border-color: #8B9AD9; }
|
||||
QLabel#ChatNotifyBadge {
|
||||
min-width: 34px;
|
||||
max-width: 34px;
|
||||
min-height: 34px;
|
||||
max-height: 34px;
|
||||
color: #FFFFFF;
|
||||
background-color: #22C55E;
|
||||
background-color: #287B65;
|
||||
border-radius: 9px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel#ChatNotifyBadge[kind="left"] { background-color: #8A94B3; }
|
||||
QLabel#ChatNotifyBadge[kind="complete"] { background-color: #3B82F6; }
|
||||
QLabel#ChatNotifyTitle { color: #1F2A44; font-size: 13px; font-weight: 700; }
|
||||
QLabel#ChatNotifyDesc { color: #4A5878; font-size: 12px; }
|
||||
QLabel#ChatNotifyTime { color: #8A94B3; font-size: 11px; }
|
||||
QLabel#ChatNotifyBadge[kind="left"] { background-color: #6A6B6D; }
|
||||
QLabel#ChatNotifyBadge[kind="complete"] { background-color: #4F63D9; }
|
||||
QLabel#ChatNotifyTitle { color: #1A1C1F; font-size: 13px; font-weight: 700; }
|
||||
QLabel#ChatNotifyDesc { color: #1A1C1F; font-size: 12px; }
|
||||
QLabel#ChatNotifyTime { color: #6A6B6D; font-size: 11px; }
|
||||
QPushButton#ChatNotifyOpen {
|
||||
min-height: 26px;
|
||||
padding: 0 10px;
|
||||
color: #3F4E75;
|
||||
background-color: #F4F6FC;
|
||||
border: 1px solid #DDE3F2;
|
||||
color: #4F63D9;
|
||||
background-color: #EEF1FA;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QPushButton#ChatNotifyOpen:hover {
|
||||
color: #4451E2;
|
||||
background-color: #EEF1FF;
|
||||
border-color: #8D9BFF;
|
||||
color: #4156C4;
|
||||
background-color: #EEF1FA;
|
||||
border-color: #8B9AD9;
|
||||
}
|
||||
QPushButton#ChatNotifyClose {
|
||||
min-width: 22px;
|
||||
max-width: 22px;
|
||||
min-height: 22px;
|
||||
max-height: 22px;
|
||||
color: #8A94B3;
|
||||
color: #6A6B6D;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
QPushButton#ChatNotifyClose:hover { color: #4A5878; background-color: #EDF0F7; }
|
||||
QPushButton#ChatNotifyClose:hover { color: #1A1C1F; background-color: #F7F7F7; }
|
||||
"""
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -134,63 +134,63 @@ def open_safe_http_url(target: str) -> bool:
|
||||
_INLINE_PLAYER_QSS = """
|
||||
QWidget#DiagnosisInlineRecordingPlayer {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E6EAF5;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QFrame#DiagnosisInlineRecordingSurface {
|
||||
background: #11182E;
|
||||
background: #1A1C1F;
|
||||
border: 0;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
QLabel#DiagnosisInlineRecordingPlaceholder {
|
||||
color: #C7D0E8;
|
||||
color: #E4E4E5;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
QLabel#DiagnosisInlineRecordingTime {
|
||||
color: #64739A;
|
||||
color: #6A6B6D;
|
||||
font-size: 11px;
|
||||
}
|
||||
QPushButton[recordingControl="true"] {
|
||||
min-height: 24px;
|
||||
max-height: 24px;
|
||||
padding: 0 8px;
|
||||
color: #3F4E75;
|
||||
background: #FAFBFE;
|
||||
border: 1px solid #D8DEEE;
|
||||
color: #1A1C1F;
|
||||
background: #F7F7F7;
|
||||
border: 1px solid #E4E4E5;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QPushButton[recordingControl="true"]:hover,
|
||||
QPushButton[recordingControl="true"]:focus {
|
||||
color: #4451E2;
|
||||
background: #F0F2FF;
|
||||
border-color: #8D9BFF;
|
||||
color: #4156C4;
|
||||
background: #EEF1FA;
|
||||
border-color: #8B9AD9;
|
||||
}
|
||||
QPushButton#DiagnosisInlineRecordingPlay {
|
||||
color: #FFFFFF;
|
||||
background: #5761F4;
|
||||
border-color: #5761F4;
|
||||
background: #4F63D9;
|
||||
border-color: #4F63D9;
|
||||
}
|
||||
QPushButton#DiagnosisInlineRecordingPlay:hover,
|
||||
QPushButton#DiagnosisInlineRecordingPlay:focus {
|
||||
color: #FFFFFF;
|
||||
background: #4C57E9;
|
||||
border-color: #4C57E9;
|
||||
background: #4156C4;
|
||||
border-color: #4156C4;
|
||||
}
|
||||
QPushButton[recordingControl="true"]:disabled {
|
||||
color: #A4ADC3;
|
||||
background: #F0F2F8;
|
||||
border-color: #E6EAF5;
|
||||
color: #8E8F90;
|
||||
background: #F7F7F7;
|
||||
border-color: #EDEDEE;
|
||||
}
|
||||
QSlider::groove:horizontal { height: 3px; background: #D8DEEE; border-radius: 1px; }
|
||||
QSlider::sub-page:horizontal { background: #5761F4; border-radius: 1px; }
|
||||
QSlider::groove:horizontal { height: 3px; background: #E4E4E5; border-radius: 1px; }
|
||||
QSlider::sub-page:horizontal { background: #4F63D9; border-radius: 1px; }
|
||||
QSlider::handle:horizontal {
|
||||
width: 10px;
|
||||
margin: -4px 0;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #5761F4;
|
||||
border: 1px solid #4F63D9;
|
||||
border-radius: 5px;
|
||||
}
|
||||
"""
|
||||
@@ -466,11 +466,11 @@ class RecordingPlaybackCell(QWidget):
|
||||
separator = QFrame()
|
||||
separator.setObjectName("DiagnosisRecordingAlternateSeparator")
|
||||
separator.setFrameShape(QFrame.Shape.HLine)
|
||||
separator.setStyleSheet("color:#E6EAF5;")
|
||||
separator.setStyleSheet("color:#EDEDEE;")
|
||||
layout.addWidget(separator)
|
||||
label = QLabel("备用地址")
|
||||
label.setObjectName("DiagnosisRecordingAlternateLabel")
|
||||
label.setStyleSheet("color:#7886AA; font-size:12px;")
|
||||
label.setStyleSheet("color:#606163; font-size:12px;")
|
||||
layout.addWidget(label)
|
||||
links = QHBoxLayout()
|
||||
links.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -490,7 +490,7 @@ class RecordingPlaybackCell(QWidget):
|
||||
layout.addLayout(links)
|
||||
self.link_status = QLabel("")
|
||||
self.link_status.setObjectName("DiagnosisRecordingLinkStatus")
|
||||
self.link_status.setStyleSheet("color:#D94856; font-size:11px;")
|
||||
self.link_status.setStyleSheet("color:#BE4B58; font-size:11px;")
|
||||
self.link_status.setWordWrap(True)
|
||||
self.link_status.hide()
|
||||
layout.addWidget(self.link_status)
|
||||
@@ -722,41 +722,41 @@ def image_display_name(target: str, ordinal: int) -> str:
|
||||
|
||||
_IMAGE_PREVIEW_QSS = """
|
||||
QDialog#DiagnosisImagePreview { background: #FFFFFF; }
|
||||
QLabel#DiagnosisImagePreviewName { color: #1F2A44; font-size: 14px; font-weight: 600; }
|
||||
QLabel#DiagnosisImagePreviewCounter { color: #64739A; font-size: 12px; }
|
||||
QLabel#DiagnosisImagePreviewStatus { color: #64739A; font-size: 12px; }
|
||||
QLabel#DiagnosisImagePreviewStatus[kind="danger"] { color: #C0392B; }
|
||||
QLabel#DiagnosisImagePreviewStatus[kind="warning"] { color: #9A650F; }
|
||||
QLabel#DiagnosisImagePreviewName { color: #1A1C1F; font-size: 14px; font-weight: 600; }
|
||||
QLabel#DiagnosisImagePreviewCounter { color: #6A6B6D; font-size: 12px; }
|
||||
QLabel#DiagnosisImagePreviewStatus { color: #6A6B6D; font-size: 12px; }
|
||||
QLabel#DiagnosisImagePreviewStatus[kind="danger"] { color: #BE4B58; }
|
||||
QLabel#DiagnosisImagePreviewStatus[kind="warning"] { color: #A9691D; }
|
||||
QScrollArea#DiagnosisImagePreviewViewport {
|
||||
background: #11182E;
|
||||
border: 1px solid #E6EAF5;
|
||||
background: #1A1C1F;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QLabel#DiagnosisImagePreviewCanvas {
|
||||
background: #11182E;
|
||||
color: #C7D0E8;
|
||||
background: #1A1C1F;
|
||||
color: #E4E4E5;
|
||||
font-size: 12px;
|
||||
}
|
||||
QPushButton[imagePreviewControl="true"] {
|
||||
min-height: 28px;
|
||||
padding: 0 12px;
|
||||
color: #3F4E75;
|
||||
background: #FAFBFE;
|
||||
border: 1px solid #D8DEEE;
|
||||
color: #1A1C1F;
|
||||
background: #F7F7F7;
|
||||
border: 1px solid #E4E4E5;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QPushButton[imagePreviewControl="true"]:hover,
|
||||
QPushButton[imagePreviewControl="true"]:focus {
|
||||
color: #4451E2;
|
||||
background: #F0F2FF;
|
||||
border-color: #8D9BFF;
|
||||
color: #4156C4;
|
||||
background: #EEF1FA;
|
||||
border-color: #8B9AD9;
|
||||
}
|
||||
QPushButton[imagePreviewControl="true"]:disabled {
|
||||
color: #A4ADC3;
|
||||
background: #F0F2F8;
|
||||
border-color: #E6EAF5;
|
||||
color: #8E8F90;
|
||||
background: #F7F7F7;
|
||||
border-color: #EDEDEE;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,13 +18,13 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..infinite_list import InfiniteList
|
||||
from ..theme import mark_business_dialog
|
||||
from ..widgets import (
|
||||
BusyOverlay,
|
||||
EmptyState,
|
||||
MessageBanner,
|
||||
OverlayHost,
|
||||
Pager,
|
||||
SortableTable,
|
||||
TableColumn,
|
||||
first_value,
|
||||
@@ -33,7 +33,6 @@ from ..widgets import (
|
||||
get_value,
|
||||
invoke,
|
||||
page_items,
|
||||
page_total,
|
||||
run_async,
|
||||
)
|
||||
from .ai_consult import can_open_ai_consult, present_ai_consult
|
||||
@@ -220,8 +219,8 @@ class AiConsultTargetDialog(QDialog):
|
||||
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)
|
||||
self.pager = InfiniteList(self.PAGE_SIZE, self)
|
||||
self.pager.bind(self.table)
|
||||
root.addWidget(self.pager)
|
||||
|
||||
self.buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel, self)
|
||||
@@ -266,42 +265,41 @@ class AiConsultTargetDialog(QDialog):
|
||||
self.load(1)
|
||||
|
||||
def retry(self) -> None:
|
||||
self.load(self._page)
|
||||
|
||||
def _change_page(self, page: int) -> None:
|
||||
self.load(page)
|
||||
self.load(1)
|
||||
|
||||
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)
|
||||
if keyword_snapshot != getattr(self, "_loaded_keyword", None):
|
||||
self._invalidate_selection()
|
||||
self._loaded_keyword = keyword_snapshot
|
||||
self._set_loading(not self.pager.rows)
|
||||
self.banner.clear()
|
||||
|
||||
run_async(
|
||||
lambda: invoke(
|
||||
self.pager.reload(
|
||||
lambda requested_page: invoke(
|
||||
self.repository,
|
||||
"list_ai_patient_options",
|
||||
page_no=page_snapshot,
|
||||
page_no=requested_page,
|
||||
page_size=self.PAGE_SIZE,
|
||||
keyword=keyword_snapshot,
|
||||
),
|
||||
on_success=lambda result: self._apply_result(
|
||||
result, generation, page_snapshot, keyword_snapshot
|
||||
apply=lambda result: self._apply_result(
|
||||
result, generation, keyword_snapshot
|
||||
),
|
||||
on_error=lambda error: self._apply_error(error, generation),
|
||||
runner=run_async,
|
||||
query_key=(keyword_snapshot,),
|
||||
on_finished=lambda: self._finish_loading(generation),
|
||||
)
|
||||
|
||||
def _apply_result(
|
||||
self,
|
||||
result: Any,
|
||||
generation: int,
|
||||
page_snapshot: int,
|
||||
keyword_snapshot: str,
|
||||
) -> None:
|
||||
if not self._is_current(generation):
|
||||
@@ -316,36 +314,32 @@ class AiConsultTargetDialog(QDialog):
|
||||
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._page = self.pager.page
|
||||
self.empty_state.setVisible(not targets and not self.pager.has_more)
|
||||
self.table.setVisible(bool(targets) or self.pager.has_more)
|
||||
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)
|
||||
if not self.pager.rows:
|
||||
self.table.set_rows(())
|
||||
self.table.setSortingEnabled(False)
|
||||
self.table.clearSelection()
|
||||
self.table.hide()
|
||||
self.empty_state.show()
|
||||
self.banner.show_message(
|
||||
f"患者诊单加载失败:{friendly_error(error)}", "danger"
|
||||
)
|
||||
self._set_loading(False)
|
||||
|
||||
def _finish_loading(self, generation: int) -> None:
|
||||
if self._is_current(generation):
|
||||
self._set_loading(False)
|
||||
|
||||
def _is_current(self, generation: int) -> bool:
|
||||
return self._active and generation == self._generation
|
||||
|
||||
@@ -365,7 +359,6 @@ class AiConsultTargetDialog(QDialog):
|
||||
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:
|
||||
@@ -383,6 +376,7 @@ class AiConsultTargetDialog(QDialog):
|
||||
def done(self, result: int) -> None:
|
||||
self._active = False
|
||||
self._generation += 1
|
||||
self.pager.invalidate()
|
||||
self._search_timer.stop()
|
||||
super().done(result)
|
||||
|
||||
|
||||
@@ -130,9 +130,9 @@ class AppUpdateDialog(QDialog):
|
||||
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;"
|
||||
"color:#A9691D;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;"
|
||||
else "color:#4F63D9;background:#EEF1FA;border-radius:8px;padding:4px 10px;font-weight:600;"
|
||||
)
|
||||
root.addWidget(self.badge, 0, Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
@@ -146,7 +146,7 @@ class AppUpdateDialog(QDialog):
|
||||
latest = offer.latest_version or "新版本"
|
||||
self.version_label = QLabel(f"当前版本 {current} → 最新版本 {latest}")
|
||||
self.version_label.setObjectName("UpdateVersionLabel")
|
||||
self.version_label.setStyleSheet("color:#7886AA;")
|
||||
self.version_label.setStyleSheet("color:#606163;")
|
||||
root.addWidget(self.version_label)
|
||||
|
||||
self.notes = QTextEdit()
|
||||
@@ -160,7 +160,7 @@ class AppUpdateDialog(QDialog):
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setObjectName("UpdateStatus")
|
||||
self.status_label.setWordWrap(True)
|
||||
self.status_label.setStyleSheet("color:#3F4E75;")
|
||||
self.status_label.setStyleSheet("color:#1A1C1F;")
|
||||
self.status_label.hide()
|
||||
root.addWidget(self.status_label)
|
||||
|
||||
@@ -175,7 +175,7 @@ class AppUpdateDialog(QDialog):
|
||||
|
||||
self.progress_text = QLabel("")
|
||||
self.progress_text.setObjectName("UpdateProgressText")
|
||||
self.progress_text.setStyleSheet("color:#7886AA;font-size:12px;")
|
||||
self.progress_text.setStyleSheet("color:#606163;font-size:12px;")
|
||||
self.progress_text.hide()
|
||||
root.addWidget(self.progress_text)
|
||||
|
||||
@@ -225,7 +225,7 @@ class AppUpdateDialog(QDialog):
|
||||
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.setStyleSheet("color:#1A1C1F;")
|
||||
self.status_label.setText("正在下载安装包…")
|
||||
self.status_label.show()
|
||||
if total > 0:
|
||||
@@ -237,7 +237,7 @@ class AppUpdateDialog(QDialog):
|
||||
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.setStyleSheet("color:#1A1C1F;")
|
||||
self.status_label.setText(message)
|
||||
self.status_label.show()
|
||||
self.progress.show()
|
||||
@@ -251,7 +251,7 @@ class AppUpdateDialog(QDialog):
|
||||
def show_error(self, message: str) -> None:
|
||||
self._busy = False
|
||||
self.status_label.setText(message)
|
||||
self.status_label.setStyleSheet("color:#F15B67;")
|
||||
self.status_label.setStyleSheet("color:#BE4B58;")
|
||||
self.status_label.show()
|
||||
self.progress.hide()
|
||||
self.progress_text.hide()
|
||||
|
||||
@@ -60,6 +60,7 @@ from ..diagnosis_drawer import (
|
||||
)
|
||||
from ..diagnosis_editors import DailyRecordEditorDialog
|
||||
from ..diagnosis_media import RecordingPlaybackCell, RecordingPlayerDialog
|
||||
from ..infinite_list import InfiniteList
|
||||
from ..widgets import (
|
||||
display_text,
|
||||
first_value,
|
||||
@@ -190,74 +191,74 @@ _ORDER_OFFSET_HELP = (
|
||||
|
||||
_ORDER_DETAIL_QSS = """
|
||||
QDialog#DiagnosisOrderDetailOverlay { background: transparent; }
|
||||
QFrame#DiagnosisOrderDetailScrim { background: rgba(30, 64, 175, 0.18); border: 0; }
|
||||
QFrame#DiagnosisOrderDetailScrim { background: rgba(26, 28, 31, 0.18); border: 0; }
|
||||
QFrame#DiagnosisOrderDetailDrawer {
|
||||
background: #F7F9FE;
|
||||
background: #F7F7F7;
|
||||
border: 0;
|
||||
border-left: 1px solid #DDE7FF;
|
||||
border-left: 1px solid #F0F0F0;
|
||||
}
|
||||
QFrame#DiagnosisOrderDetailHeader {
|
||||
background: #FFFFFF;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #DDE7FF;
|
||||
border-bottom: 1px solid #F0F0F0;
|
||||
}
|
||||
QLabel#DiagnosisOrderDetailTitle { color: #15224A; font-size: 19px; font-weight: 650; }
|
||||
QLabel#DiagnosisOrderDetailMeta { color: #7481A3; font-size: 12px; }
|
||||
QLabel#DiagnosisOrderDetailTitle { color: #1A1C1F; font-size: 19px; font-weight: 650; }
|
||||
QLabel#DiagnosisOrderDetailMeta { color: #606163; font-size: 12px; }
|
||||
QLabel#DiagnosisOrderReadonlyBadge {
|
||||
color: #3F4E75;
|
||||
background: #F7F9FE;
|
||||
border: 1px solid #E2E7F4;
|
||||
color: #1A1C1F;
|
||||
background: #F7F7F7;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 4px;
|
||||
padding: 3px 7px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QScrollArea#DiagnosisOrderDetailScroll { border: 0; background: #F7F9FE; }
|
||||
QScrollArea#DiagnosisOrderDetailScroll > QWidget > QWidget { background: #F7F9FE; }
|
||||
QScrollArea#DiagnosisOrderDetailScroll { border: 0; background: #F7F7F7; }
|
||||
QScrollArea#DiagnosisOrderDetailScroll > QWidget > QWidget { background: #F7F7F7; }
|
||||
QFrame[orderAmountCard="true"] {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #DDE7FF;
|
||||
border: 1px solid #F0F0F0;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QLabel[orderAmountTitle="true"] { color: #7481A3; font-size: 11px; font-weight: 550; }
|
||||
QLabel[orderAmountTitle="true"] { color: #606163; font-size: 11px; font-weight: 550; }
|
||||
QLabel[orderAmountValue="true"] {
|
||||
color: #15224A;
|
||||
color: #1A1C1F;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel[orderAmountTone="danger"] { color: #C43E55; }
|
||||
QLabel[orderAmountTone="success"] { color: #16876C; }
|
||||
QLabel[orderAmountTone="warning"] { color: #9A6813; }
|
||||
QLabel[orderAmountTone="danger"] { color: #BE4B58; }
|
||||
QLabel[orderAmountTone="success"] { color: #287B65; }
|
||||
QLabel[orderAmountTone="warning"] { color: #A9691D; }
|
||||
QFrame[orderDetailSection="true"] {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E2E7F4;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 10px;
|
||||
}
|
||||
QLabel[orderSectionTitle="true"] { color: #15224A; font-size: 15px; font-weight: 650; }
|
||||
QLabel[orderSectionHint="true"] { color: #7481A3; font-size: 11px; }
|
||||
QLabel[orderSectionTitle="true"] { color: #1A1C1F; font-size: 15px; font-weight: 650; }
|
||||
QLabel[orderSectionHint="true"] { color: #606163; font-size: 11px; }
|
||||
QFrame[orderField="true"] {
|
||||
background: #F2F6FE;
|
||||
border: 1px solid #E2E7F4;
|
||||
background: #F7F7F7;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 7px;
|
||||
}
|
||||
QLabel[orderFieldLabel="true"] { color: #7481A3; font-size: 11px; }
|
||||
QLabel[orderFieldValue="true"] { color: #15224A; font-size: 13px; }
|
||||
QLabel[orderFieldLabel="true"] { color: #6A6B6D; font-size: 11px; }
|
||||
QLabel[orderFieldValue="true"] { color: #1A1C1F; font-size: 13px; }
|
||||
QLabel[orderEmptyState="true"] {
|
||||
color: #7481A3;
|
||||
background: #F2F6FE;
|
||||
border: 1px dashed #C9D8F2;
|
||||
color: #606163;
|
||||
background: #F7F7F7;
|
||||
border: 1px dashed #E4E4E5;
|
||||
border-radius: 5px;
|
||||
padding: 18px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
QFrame#DiagnosisOrderTimelineItem { border: 0; border-left: 2px solid #93B4F4; }
|
||||
QLabel#DiagnosisOrderTimelineTime { color: #7481A3; font-size: 11px; }
|
||||
QLabel#DiagnosisOrderTimelineTitle { color: #15224A; font-size: 12px; font-weight: 600; }
|
||||
QLabel#DiagnosisOrderTimelineBody { color: #7481A3; font-size: 12px; }
|
||||
QFrame#DiagnosisOrderTimelineItem { border: 0; border-left: 2px solid #8B9AD9; }
|
||||
QLabel#DiagnosisOrderTimelineTime { color: #606163; font-size: 11px; }
|
||||
QLabel#DiagnosisOrderTimelineTitle { color: #1A1C1F; font-size: 12px; font-weight: 600; }
|
||||
QLabel#DiagnosisOrderTimelineBody { color: #606163; font-size: 12px; }
|
||||
QFrame#DiagnosisOrderDetailFooter {
|
||||
background: #FFFFFF;
|
||||
border: 0;
|
||||
border-top: 1px solid #DDE7FF;
|
||||
border-top: 1px solid #F0F0F0;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -1003,8 +1004,8 @@ class DiagnosisDialog(QDialog):
|
||||
self.readonly_back_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.readonly_back_button.setStyleSheet(
|
||||
"QPushButton{height:32px;padding:0 8px;border:0;background:transparent;"
|
||||
"color:#5265F6;font-size:13px;font-weight:500;}"
|
||||
"QPushButton:hover,QPushButton:focus{background:#F0F2FF;border-radius:6px;}"
|
||||
"color:#1A1C1F;font-size:13px;font-weight:500;}"
|
||||
"QPushButton:hover,QPushButton:focus{background:#EEF1FA;border-radius:6px;}"
|
||||
)
|
||||
self.readonly_back_button.clicked.connect(self.reject)
|
||||
left_layout.addWidget(self.readonly_back_button)
|
||||
@@ -1532,20 +1533,11 @@ class DiagnosisDialog(QDialog):
|
||||
layout.addWidget(toolbar)
|
||||
self.orders_table = self._new_table("orders", "DiagnosisTableOrders")
|
||||
layout.addWidget(self.orders_table, 1)
|
||||
footer = QHBoxLayout()
|
||||
self.orders_summary = QLabel("共 0 条")
|
||||
self.orders_summary.setObjectName("DiagnosisOrdersSummary")
|
||||
footer.addWidget(self.orders_summary)
|
||||
footer.addStretch(1)
|
||||
self.orders_previous = QPushButton("上一页")
|
||||
self.orders_previous.clicked.connect(lambda: self._change_orders_page(-1))
|
||||
footer.addWidget(self.orders_previous)
|
||||
self.orders_page_label = QLabel("1 / 1")
|
||||
footer.addWidget(self.orders_page_label)
|
||||
self.orders_next = QPushButton("下一页")
|
||||
self.orders_next.clicked.connect(lambda: self._change_orders_page(1))
|
||||
footer.addWidget(self.orders_next)
|
||||
layout.addLayout(footer)
|
||||
self.orders_list = InfiniteList(self._orders_page_size, page)
|
||||
for table in self._table_registry["orders"]:
|
||||
self.orders_list.bind(table)
|
||||
self._orders_footer_layout = layout
|
||||
layout.addWidget(self.orders_list)
|
||||
return page
|
||||
|
||||
def _wrap_tab(self, object_name: str, body: QWidget) -> QScrollArea:
|
||||
@@ -2021,6 +2013,13 @@ class DiagnosisDialog(QDialog):
|
||||
self._daily_todo_status = None
|
||||
self._orders_page = 1
|
||||
self._orders_total = 0
|
||||
self.orders_list.reset()
|
||||
footer_layout = (
|
||||
self._readonly_sections["orders"].layout()
|
||||
if self._standalone_readonly
|
||||
else self._orders_footer_layout
|
||||
)
|
||||
footer_layout.addWidget(self.orders_list)
|
||||
self._detail = authoritative_detail if authoritative_detail is not None else seed
|
||||
self.save_button.set_state("idle")
|
||||
self.refresh_permissions()
|
||||
@@ -2315,6 +2314,7 @@ class DiagnosisDialog(QDialog):
|
||||
self._generation += 1
|
||||
self._save_generation += 1
|
||||
self._orders_generation += 1
|
||||
self.orders_list.invalidate()
|
||||
self._order_detail_generation += 1
|
||||
self._daily_mutation_generation += 1
|
||||
self._notes_mutation_generation += 1
|
||||
@@ -2639,6 +2639,9 @@ class DiagnosisDialog(QDialog):
|
||||
return
|
||||
if not force and (key in self._loaded_tabs or key in self._loading_tabs):
|
||||
return
|
||||
if key == "orders":
|
||||
self._load_orders()
|
||||
return
|
||||
self._tab_generations[key] += 1
|
||||
generation = self._tab_generations[key]
|
||||
diagnosis_id = self._diagnosis_id
|
||||
@@ -2732,10 +2735,9 @@ class DiagnosisDialog(QDialog):
|
||||
self._fill_prescriptions(page_items(result))
|
||||
elif key == "orders":
|
||||
rows = page_items(result)
|
||||
self._orders_page = 1
|
||||
self._orders_page = self.orders_list.page
|
||||
self._orders_total = page_total(result, len(rows))
|
||||
self._fill_orders(rows)
|
||||
self._update_orders_pager()
|
||||
elif key == "assign":
|
||||
self._fill_assignments(page_items(result))
|
||||
elif key == "appointment":
|
||||
@@ -3448,8 +3450,7 @@ class DiagnosisDialog(QDialog):
|
||||
panel.set_unavailable("切换到聊天记录后加载归档数据。")
|
||||
for panel in self._daily_panels:
|
||||
panel.clear()
|
||||
self.orders_summary.setText("共 0 条")
|
||||
self.orders_page_label.setText("1 / 1")
|
||||
self.orders_list.update_state(1, 0)
|
||||
|
||||
@staticmethod
|
||||
def _set_row(table: QTableWidget, row: int, values: Sequence[Any]) -> None:
|
||||
@@ -4067,6 +4068,9 @@ class DiagnosisDialog(QDialog):
|
||||
)
|
||||
for row_index, row in enumerate(rows):
|
||||
order_id = _int(first_value(row, "id", "order_id"), 0)
|
||||
item = table.item(row_index, 0)
|
||||
if item is not None:
|
||||
item.setData(Qt.ItemDataRole.UserRole, row)
|
||||
if (
|
||||
self._can_order_detail
|
||||
and order_id > 0
|
||||
@@ -4738,52 +4742,44 @@ class DiagnosisDialog(QDialog):
|
||||
if generation == self._order_detail_generation and diagnosis_id == self._diagnosis_id:
|
||||
self._show_message(friendly_error(error), "danger")
|
||||
|
||||
def _update_orders_pager(self) -> None:
|
||||
pages = max(1, (self._orders_total + self._orders_page_size - 1) // self._orders_page_size)
|
||||
self.orders_summary.setText(f"共 {self._orders_total} 条")
|
||||
self.orders_page_label.setText(f"{self._orders_page} / {pages}")
|
||||
self.orders_previous.setEnabled(self._orders_page > 1)
|
||||
self.orders_next.setEnabled(self._orders_page < pages)
|
||||
|
||||
def _change_orders_page(self, offset: int) -> None:
|
||||
def _load_orders(self) -> None:
|
||||
if not self._can_patient_orders or self._diagnosis_id <= 0:
|
||||
return
|
||||
pages = max(1, (self._orders_total + self._orders_page_size - 1) // self._orders_page_size)
|
||||
target_page = self._orders_page + offset
|
||||
if target_page < 1 or target_page > pages:
|
||||
return
|
||||
self._orders_generation += 1
|
||||
generation = self._orders_generation
|
||||
self._tab_generations["orders"] += 1
|
||||
generation = self._tab_generations["orders"]
|
||||
diagnosis_id = self._diagnosis_id
|
||||
patient_id = self._patient_id
|
||||
self._show_message("正在加载患者订单…", "info")
|
||||
run_async(
|
||||
lambda: self._query_orders(diagnosis_id, patient_id, target_page),
|
||||
on_success=lambda result: self._apply_orders_page(
|
||||
result, diagnosis_id, target_page, generation
|
||||
self._loading_tabs.add("orders")
|
||||
if not self.orders_list.rows:
|
||||
self._set_tab_loading("orders")
|
||||
self.orders_list.reload(
|
||||
lambda page: self._query_orders(diagnosis_id, patient_id, page),
|
||||
apply=lambda result: self._apply_orders_result(
|
||||
result, diagnosis_id, generation
|
||||
),
|
||||
on_error=lambda error: self._orders_error(error, diagnosis_id, generation),
|
||||
on_error=lambda error: self._tab_load_error(
|
||||
"orders", error, diagnosis_id, generation
|
||||
),
|
||||
runner=run_async,
|
||||
query_key=(diagnosis_id, patient_id),
|
||||
)
|
||||
|
||||
def _apply_orders_page(
|
||||
def _apply_orders_result(
|
||||
self,
|
||||
result: Any,
|
||||
diagnosis_id: int,
|
||||
page: int,
|
||||
generation: int,
|
||||
) -> None:
|
||||
if generation != self._orders_generation or diagnosis_id != self._diagnosis_id:
|
||||
if (
|
||||
generation != self._tab_generations["orders"]
|
||||
or diagnosis_id != self._diagnosis_id
|
||||
or not self._authoritative_detail_loaded
|
||||
):
|
||||
return
|
||||
rows = page_items(result)
|
||||
self._orders_page = page
|
||||
self._orders_total = page_total(result, len(rows))
|
||||
self._fill_orders(rows)
|
||||
self._update_orders_pager()
|
||||
self._clear_message()
|
||||
|
||||
def _orders_error(self, error: Exception, diagnosis_id: int, generation: int) -> None:
|
||||
if generation == self._orders_generation and diagnosis_id == self._diagnosis_id:
|
||||
self._show_message(friendly_error(error), "danger")
|
||||
if self.orders_list.page == 0:
|
||||
self._fill_orders([])
|
||||
return
|
||||
self._apply_tab_result("orders", result, diagnosis_id, generation)
|
||||
|
||||
def _save(self) -> None:
|
||||
if (
|
||||
|
||||
@@ -40,76 +40,76 @@ _STATUS_LABELS = {
|
||||
"invalid": "无效录音",
|
||||
}
|
||||
_STATUS_COLORS = {
|
||||
"recording": "#5364F5",
|
||||
"pending": "#B26A00",
|
||||
"uploading": "#2F6FEB",
|
||||
"uploaded": "#07966B",
|
||||
"failed": "#DC4054",
|
||||
"invalid": "#7886AA",
|
||||
"recording": "#1A1C1F",
|
||||
"pending": "#A9691D",
|
||||
"uploading": "#1A1C1F",
|
||||
"uploaded": "#287B65",
|
||||
"failed": "#BE4B58",
|
||||
"invalid": "#606163",
|
||||
}
|
||||
_BUSINESS_TIMEZONE = timezone(timedelta(hours=8))
|
||||
|
||||
_LOCAL_AUDIO_QSS = """
|
||||
QDialog#LocalAudioQueueDialog {
|
||||
background: #F6F8FD;
|
||||
color: #111F46;
|
||||
background: #F7F7F7;
|
||||
color: #1A1C1F;
|
||||
}
|
||||
QFrame#LocalAudioQueueHeader, QFrame#LocalAudioQueueSummary,
|
||||
QFrame#LocalAudioQueueTableCard, QFrame#LocalAudioQueueFooter {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E2E7F4;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 14px;
|
||||
}
|
||||
QLabel#LocalAudioQueueTitle {
|
||||
color: #111F46;
|
||||
color: #1A1C1F;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel#LocalAudioQueueSubtitle, QLabel#LocalAudioQueueHint {
|
||||
color: #6E7C9F;
|
||||
color: #6A6B6D;
|
||||
font-size: 13px;
|
||||
}
|
||||
QLabel[queueSummary="true"] {
|
||||
background: #F3F5FB;
|
||||
border: 1px solid #E6EAF5;
|
||||
background: #F7F7F7;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 10px;
|
||||
color: #3F4E75;
|
||||
color: #1A1C1F;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
QPushButton {
|
||||
min-height: 34px;
|
||||
border: 1px solid #D9E0F2;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 9px;
|
||||
background: #FFFFFF;
|
||||
color: #354365;
|
||||
color: #1A1C1F;
|
||||
padding: 0 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QPushButton:hover { background: #F1F3FF; border-color: #AEB8FF; }
|
||||
QPushButton:disabled { color: #A5AFC6; background: #F7F8FC; }
|
||||
QPushButton:hover { background: #EEF1FA; border-color: #8B9AD9; }
|
||||
QPushButton:disabled { color: #8E8F90; background: #F7F7F7; }
|
||||
QPushButton[variant="primary"] {
|
||||
color: #FFFFFF;
|
||||
background: #5661F4;
|
||||
border-color: #5661F4;
|
||||
background: #4F63D9;
|
||||
border-color: #4F63D9;
|
||||
}
|
||||
QPushButton[variant="danger"] { color: #D83E51; background: #FFF6F7; }
|
||||
QPushButton[variant="danger"] { color: #BE4B58; background: #FFF6F7; }
|
||||
QTableWidget#LocalAudioQueueTable {
|
||||
background: #FFFFFF;
|
||||
alternate-background-color: #FAFBFE;
|
||||
alternate-background-color: #F7F7F7;
|
||||
border: 0;
|
||||
gridline-color: #E8ECF5;
|
||||
color: #263452;
|
||||
selection-background-color: #EEF1FF;
|
||||
selection-color: #111F46;
|
||||
gridline-color: #EDEDEE;
|
||||
color: #1A1C1F;
|
||||
selection-background-color: #EEF1FA;
|
||||
selection-color: #1A1C1F;
|
||||
}
|
||||
QTableWidget#LocalAudioQueueTable::item { padding: 8px; }
|
||||
QHeaderView::section {
|
||||
background: #F5F7FC;
|
||||
color: #53617F;
|
||||
background: #F7F7F7;
|
||||
color: #6A6B6D;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #E1E6F1;
|
||||
border-bottom: 1px solid #EDEDEE;
|
||||
padding: 10px 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -371,7 +371,7 @@ class LocalAudioQueueDialog(QDialog):
|
||||
self._status_column,
|
||||
_STATUS_LABELS.get(record.status, record.status),
|
||||
)
|
||||
status_item.setForeground(QColor(_STATUS_COLORS.get(record.status, "#53617F")))
|
||||
status_item.setForeground(QColor(_STATUS_COLORS.get(record.status, "#606163")))
|
||||
status_item.setToolTip(
|
||||
f"已尝试 {record.attempts} 次"
|
||||
+ (f"\nCOS:{record.uploaded_url}" if record.uploaded_url else "")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -143,75 +143,75 @@ def diagnosis_ai_task(prompt: str) -> str:
|
||||
|
||||
PRESCRIPTION_AI_QSS = """
|
||||
QDialog#PrescriptionAiDialog {
|
||||
color: #17203F;
|
||||
background-color: #F7F9FE;
|
||||
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
||||
color: #1A1C1F;
|
||||
background-color: #F7F7F7;
|
||||
|
||||
font-size: 13px;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QPushButton {
|
||||
min-height: 34px;
|
||||
padding: 0 16px;
|
||||
color: #4F5B75;
|
||||
color: #1A1C1F;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #DCE3F2;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 7px;
|
||||
font-weight: 500;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QPushButton:hover {
|
||||
color: #4D57D8;
|
||||
background-color: #F0F2FF;
|
||||
border-color: #D8DCFF;
|
||||
color: #4156C4;
|
||||
background-color: #EEF1FA;
|
||||
border-color: #8B9AD9;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QPushButton:pressed {
|
||||
color: #FFFFFF;
|
||||
background-color: #4D57D8;
|
||||
border-color: #4D57D8;
|
||||
background-color: #354BB4;
|
||||
border-color: #354BB4;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QPushButton[variant="primary"] {
|
||||
color: #FFFFFF;
|
||||
background-color: #5761F4;
|
||||
border-color: #5761F4;
|
||||
background-color: #4F63D9;
|
||||
border-color: #4F63D9;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QPushButton[variant="primary"]:hover {
|
||||
background-color: #6871F6;
|
||||
border-color: #6871F6;
|
||||
background-color: #4156C4;
|
||||
border-color: #4156C4;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QPushButton[variant="link"] {
|
||||
color: #4D57D8;
|
||||
color: #4F63D9;
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QPushButton[variant="link"]:hover {
|
||||
background-color: #F0F2FF;
|
||||
background-color: #EEF1FA;
|
||||
}
|
||||
QLabel#PrescriptionAiTitle {
|
||||
color: #17203F;
|
||||
color: #1A1C1F;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel#PrescriptionAiSubtitle {
|
||||
color: #78849D;
|
||||
color: #606163;
|
||||
font-size: 13px;
|
||||
}
|
||||
QFrame#PrescriptionAiSnapshot {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #DCE3F2;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 10px;
|
||||
}
|
||||
QLabel#PrescriptionAiSnapshotLabel {
|
||||
color: #4D57D8;
|
||||
color: #1A1C1F;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QLabel#PrescriptionAiSnapshotBody {
|
||||
color: #26304F;
|
||||
color: #1A1C1F;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
QFrame#PrescriptionAiSummary {
|
||||
background-color: #F0F2FF;
|
||||
background-color: #F0F0F0;
|
||||
border: 0;
|
||||
border-left: 3px solid #5761F4;
|
||||
border-left: 3px solid #4F63D9;
|
||||
border-radius: 0 9px 9px 0;
|
||||
}
|
||||
QFrame#PrescriptionAiCaution {
|
||||
@@ -220,56 +220,56 @@ QFrame#PrescriptionAiCaution {
|
||||
border-radius: 9px;
|
||||
}
|
||||
QLabel#PrescriptionAiSectionTitle {
|
||||
color: #17203F;
|
||||
color: #1A1C1F;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QLabel#PrescriptionAiBody {
|
||||
color: #37415E;
|
||||
color: #1A1C1F;
|
||||
font-size: 13px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
QTextBrowser#PrescriptionAiAnswer {
|
||||
color: #34436B;
|
||||
color: #1A1C1F;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E3E8F4;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
selection-color: #17203F;
|
||||
selection-background-color: #DDE2FF;
|
||||
selection-color: #1A1C1F;
|
||||
selection-background-color: #EEF1FA;
|
||||
}
|
||||
QLabel#PrescriptionAiMuted {
|
||||
color: #8A94AA;
|
||||
color: #6A6B6D;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
QFrame#PrescriptionAiMeta {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #E3E8F4;
|
||||
border-bottom: 1px solid #EDEDEE;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QTabWidget::pane {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #DCE3F2;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 10px;
|
||||
top: -1px;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QTabBar::tab {
|
||||
min-height: 36px;
|
||||
padding: 0 18px;
|
||||
color: #78849D;
|
||||
color: #606163;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QTabBar::tab:hover {
|
||||
color: #4D57D8;
|
||||
background-color: #F5F7FC;
|
||||
color: #4156C4;
|
||||
background-color: #F7F7F7;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QTabBar::tab:selected {
|
||||
color: #4D57D8;
|
||||
background-color: #F0F2FF;
|
||||
border-bottom: 2px solid #5761F4;
|
||||
color: #4F63D9;
|
||||
background-color: #EEF1FA;
|
||||
border-bottom: 2px solid #4F63D9;
|
||||
font-weight: 600;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QScrollArea,
|
||||
@@ -278,15 +278,15 @@ QDialog#PrescriptionAiDialog QScrollArea > QWidget > QWidget {
|
||||
border: 0;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QTextEdit {
|
||||
color: #17203F;
|
||||
color: #1A1C1F;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #DCE3F2;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
selection-background-color: #E3E6FF;
|
||||
selection-background-color: #EEF1FA;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QTextEdit:focus {
|
||||
border: 1px solid #5761F4;
|
||||
border: 1px solid #8B9AD9;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QScrollBar:vertical {
|
||||
width: 10px;
|
||||
@@ -295,7 +295,7 @@ QDialog#PrescriptionAiDialog QScrollBar:vertical {
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QScrollBar::handle:vertical {
|
||||
min-height: 32px;
|
||||
background-color: #C8D0E0;
|
||||
background-color: #D2D2D3;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QDialog#PrescriptionAiDialog QScrollBar::add-line:vertical,
|
||||
@@ -305,20 +305,20 @@ QDialog#PrescriptionAiDialog QScrollBar::sub-line:vertical {
|
||||
"""
|
||||
|
||||
_AI_ANSWER_DOCUMENT_CSS = (
|
||||
"body { color:#34436B; font-size:14px; line-height:1.72; } "
|
||||
"h1 { color:#15224A; font-size:20px; margin:14px 0 8px; line-height:1.4; } "
|
||||
"h2 { color:#15224A; font-size:17px; margin:14px 0 7px; line-height:1.42; } "
|
||||
"h3,h4 { color:#15224A; font-size:15px; margin:12px 0 6px; line-height:1.45; } "
|
||||
"body { color:#1A1C1F; font-size:14px; line-height:1.72; } "
|
||||
"h1 { color:#1A1C1F; font-size:20px; margin:14px 0 8px; line-height:1.4; } "
|
||||
"h2 { color:#1A1C1F; font-size:17px; margin:14px 0 7px; line-height:1.42; } "
|
||||
"h3,h4 { color:#1A1C1F; font-size:15px; margin:12px 0 6px; line-height:1.45; } "
|
||||
"p { margin:6px 0; line-height:1.72; } "
|
||||
"ul,ol { margin:7px 0 8px 22px; } li { margin:4px 0; line-height:1.65; } "
|
||||
"strong { color:#15224A; font-weight:700; } "
|
||||
"blockquote { color:#596788; background:#F5F7FC; border-left:3px solid #7B84F7; "
|
||||
"strong { color:#1A1C1F; font-weight:700; } "
|
||||
"blockquote { color:#606163; background:#F7F7F7; border-left:3px solid #8B9AD9; "
|
||||
"margin:9px 0; padding:7px 10px; } "
|
||||
"code { color:#33406B; background:#EEF1FF; } "
|
||||
"pre { color:#33406B; background:#F0F3FA; margin:8px 0; padding:9px; } "
|
||||
"code { color:#1A1C1F; background:#F0F0F0; } "
|
||||
"pre { color:#1A1C1F; background:#F7F7F7; margin:8px 0; padding:9px; } "
|
||||
"table { border-collapse:collapse; margin:8px 0; } "
|
||||
"th,td { border:1px solid #DCE3F2; padding:6px 8px; } "
|
||||
"th { color:#15224A; background:#F5F7FC; font-weight:700; }"
|
||||
"th,td { border:1px solid #EDEDEE; padding:6px 8px; } "
|
||||
"th { color:#1A1C1F; background:#F7F7F7; font-weight:700; }"
|
||||
)
|
||||
_AI_ANSWER_MARKDOWN_FEATURES = (
|
||||
QTextDocument.MarkdownFeature.MarkdownDialectGitHub
|
||||
@@ -1181,7 +1181,7 @@ class PrescriptionAiReportDialog(QDialog):
|
||||
def _list_html(self, items: Any, empty: str) -> str:
|
||||
values = [str(item).strip() for item in (items or []) if str(item).strip()]
|
||||
if not values:
|
||||
return f'<span style="color:#8A94AA;">{html.escape(empty)}</span>'
|
||||
return f'<span style="color:#6A6B6D;">{html.escape(empty)}</span>'
|
||||
bullets = "".join(f"<li>{html.escape(item)}</li>" for item in values)
|
||||
return f'<ul style="margin:0;padding-left:18px;">{bullets}</ul>'
|
||||
|
||||
@@ -1398,7 +1398,7 @@ class PrescriptionAiReportDialog(QDialog):
|
||||
content.setTextFormat(Qt.TextFormat.RichText)
|
||||
cell_layout.addWidget(heading)
|
||||
cell_layout.addWidget(content)
|
||||
grid.addWidget(cell, index // 2, index % 2)
|
||||
grid.addWidget(cell, index // 2, index % 2, Qt.AlignmentFlag.AlignTop)
|
||||
self.host_layout.addWidget(grid_host)
|
||||
if report.get("compatibility_analysis"):
|
||||
self._section("配伍分析", str(report.get("compatibility_analysis") or ""))
|
||||
|
||||
@@ -21,6 +21,7 @@ from PySide6.QtGui import (
|
||||
QResizeEvent,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QFrame,
|
||||
QGraphicsDropShadowEffect,
|
||||
@@ -43,7 +44,7 @@ from PySide6.QtWidgets import (
|
||||
from doctor_workstation import __version__
|
||||
from doctor_workstation.resources import app_icon_path, brand_lockup_path
|
||||
|
||||
from .theme import crisp_pixmap
|
||||
from . import icons
|
||||
from .widgets import BusyOverlay, MessageBanner, friendly_error, invoke, run_async
|
||||
|
||||
|
||||
@@ -71,7 +72,7 @@ class _VisibleCheckBox(QCheckBox):
|
||||
)
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
color = QColor("#FFFFFF" if self.isEnabled() else "#98A2B3")
|
||||
color = QColor("#FFFFFF" if self.isEnabled() else "#8E8F90")
|
||||
painter.setPen(QPen(color, 2, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
|
||||
painter.drawLine(
|
||||
QPoint(indicator.left() + 4, indicator.center().y()),
|
||||
@@ -90,7 +91,7 @@ class _AccountLineEdit(QLineEdit):
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(_round_pen("#8292B6", 1.5))
|
||||
painter.setPen(_round_pen("#6A6B6D", 1.5))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawEllipse(QRectF(24, 14.5, 8, 8))
|
||||
painter.drawRoundedRect(QRectF(19, 27, 18, 9), 4.5, 4.5)
|
||||
@@ -104,7 +105,7 @@ class _DemoCheckBox(_VisibleCheckBox):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
center = QPointF(self.width() - 9, self.height() / 2)
|
||||
painter.setPen(_round_pen("#92A0BF", 1.4))
|
||||
painter.setPen(_round_pen("#8E8F90", 1.4))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawEllipse(center, 7, 7)
|
||||
painter.drawLine(center + QPointF(0, -1), center + QPointF(0, 4))
|
||||
@@ -112,10 +113,9 @@ class _DemoCheckBox(_VisibleCheckBox):
|
||||
|
||||
|
||||
def _font(pixel_size: int, weight: QFont.Weight = QFont.Weight.Normal) -> QFont:
|
||||
font = QFont("Microsoft YaHei UI")
|
||||
font = QFont(QApplication.font())
|
||||
font.setPixelSize(pixel_size)
|
||||
font.setWeight(weight)
|
||||
font.setHintingPreference(QFont.HintingPreference.PreferFullHinting)
|
||||
return font
|
||||
|
||||
|
||||
@@ -291,10 +291,10 @@ class _BrandPanel(QWidget):
|
||||
bounds = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
|
||||
background = QLinearGradient(bounds.topLeft(), bounds.bottomRight())
|
||||
background.setColorAt(0.0, QColor("#FFFFFF"))
|
||||
background.setColorAt(0.7, QColor("#FEFEFF"))
|
||||
background.setColorAt(1.0, QColor("#F9FBFF"))
|
||||
background.setColorAt(0.7, QColor("#FFFFFF"))
|
||||
background.setColorAt(1.0, QColor("#F7F7F7"))
|
||||
painter.setBrush(background)
|
||||
painter.setPen(QPen(QColor("#E4E9F4"), 1))
|
||||
painter.setPen(QPen(QColor("#EDEDEE"), 1))
|
||||
painter.drawRoundedRect(bounds, 24, 24)
|
||||
|
||||
width, height = float(self.width()), float(self.height())
|
||||
@@ -315,15 +315,15 @@ class _BrandPanel(QWidget):
|
||||
|
||||
tag_rect = QRectF(left, 238, 119, 40)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#F0F2FF"))
|
||||
painter.setBrush(QColor("#EEF1FA"))
|
||||
painter.drawRoundedRect(tag_rect, 11, 11)
|
||||
painter.setFont(_font(17, QFont.Weight.DemiBold))
|
||||
painter.setPen(QColor("#5265F6"))
|
||||
painter.setFont(_font(17, QFont.Weight.Medium))
|
||||
painter.setPen(QColor("#4F63D9"))
|
||||
painter.drawText(tag_rect, Qt.AlignmentFlag.AlignCenter, "医生工作站")
|
||||
|
||||
copy_left = left + 4
|
||||
painter.setFont(_font(51, QFont.Weight.Bold))
|
||||
painter.setPen(QColor("#14224A"))
|
||||
painter.setFont(_font(48, QFont.Weight.Medium))
|
||||
painter.setPen(QColor("#1A1C1F"))
|
||||
painter.drawText(QPointF(copy_left, 354), "把诊间工作,")
|
||||
painter.drawText(QPointF(copy_left, 424), "留在一个")
|
||||
prefix_width = painter.fontMetrics().horizontalAdvance("留在一个")
|
||||
@@ -333,8 +333,8 @@ class _BrandPanel(QWidget):
|
||||
copy_left + prefix_width + 264,
|
||||
0,
|
||||
)
|
||||
highlight.setColorAt(0.0, QColor("#4258EC"))
|
||||
highlight.setColorAt(1.0, QColor("#6975FF"))
|
||||
highlight.setColorAt(0.0, QColor("#4F63D9"))
|
||||
highlight.setColorAt(1.0, QColor("#4F63D9"))
|
||||
painter.setPen(QPen(QBrush(highlight), 1))
|
||||
painter.drawText(QPointF(copy_left + prefix_width, 424), "安静的界面里")
|
||||
suffix_x = (
|
||||
@@ -342,19 +342,19 @@ class _BrandPanel(QWidget):
|
||||
+ prefix_width
|
||||
+ painter.fontMetrics().horizontalAdvance("安静的界面里")
|
||||
)
|
||||
painter.setPen(QColor("#14224A"))
|
||||
painter.setPen(QColor("#1A1C1F"))
|
||||
painter.drawText(QPointF(suffix_x, 424), "。")
|
||||
|
||||
body_left = left + 6
|
||||
painter.setFont(_font(20))
|
||||
painter.setPen(QColor("#7181A7"))
|
||||
painter.setPen(QColor("#606163"))
|
||||
painter.drawText(
|
||||
QPointF(body_left, 492), "接诊、问诊、患者与处方信息统一呈现,"
|
||||
)
|
||||
painter.drawText(QPointF(body_left, 525), "帮助医生专注于每一次沟通。")
|
||||
|
||||
painter.setFont(_font(16))
|
||||
painter.setPen(QColor("#7484A9"))
|
||||
painter.setPen(QColor("#6A6B6D"))
|
||||
painter.drawText(
|
||||
QPointF(body_left, height - 85), "本工作站仅供获授权的医疗人员使用"
|
||||
)
|
||||
@@ -366,7 +366,7 @@ class _RevealButton(QToolButton):
|
||||
del event
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
color = QColor("#8796B8" if self.isEnabled() else "#B8C0D1")
|
||||
color = QColor("#6A6B6D" if self.isEnabled() else "#BDBDBE")
|
||||
painter.setPen(_round_pen(color, 2))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
eye = QPainterPath(QPointF(7, self.height() / 2))
|
||||
@@ -392,11 +392,11 @@ class _ServerButton(QPushButton):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
rect = QRectF(self.rect()).adjusted(0.75, 0.75, -0.75, -0.75)
|
||||
painter.setBrush(QColor("#F8FAFF") if self.underMouse() else QColor("#FFFFFF"))
|
||||
painter.setPen(QPen(QColor("#D8DFEE"), 1.5))
|
||||
painter.setBrush(QColor("#FFFFFF") if self.underMouse() else QColor("#FFFFFF"))
|
||||
painter.setPen(QPen(QColor("#E4E4E5"), 1.5))
|
||||
painter.drawRoundedRect(rect, 12, 12)
|
||||
color = QColor("#17264B" if self.isEnabled() else "#A2ABC0")
|
||||
painter.setPen(_round_pen("#7C8DB2", 1.8))
|
||||
color = QColor("#1A1C1F" if self.isEnabled() else "#8E8F90")
|
||||
painter.setPen(_round_pen("#6A6B6D", 1.8))
|
||||
center = QPointF(29, self.height() / 2)
|
||||
painter.drawEllipse(center, 8, 8)
|
||||
painter.drawEllipse(center, 2.8, 2.8)
|
||||
@@ -420,7 +420,7 @@ class _ServerButton(QPushButton):
|
||||
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
|
||||
"服务器设置",
|
||||
)
|
||||
painter.setPen(_round_pen("#94A1BC", 2))
|
||||
painter.setPen(_round_pen("#8E8F90", 2))
|
||||
x, y = self.width() - 28, self.height() / 2
|
||||
if self.isChecked():
|
||||
painter.drawLine(QPointF(x - 5, y + 3), QPointF(x, y - 3))
|
||||
@@ -482,12 +482,8 @@ class LoginWindow(QMainWindow):
|
||||
canvas.setStyleSheet(
|
||||
"""
|
||||
QWidget#LoginCanvas {
|
||||
color: #17264B;
|
||||
background: qlineargradient(
|
||||
x1:0, y1:0, x2:1, y2:1,
|
||||
stop:0 #F8FAFF, stop:0.58 #FBFCFF, stop:1 #F1F5FF
|
||||
);
|
||||
font-family: "Microsoft YaHei UI";
|
||||
color: #1A1C1F;
|
||||
background-color: #F4F6FA;
|
||||
font-size: 16px;
|
||||
}
|
||||
QWidget#LoginBrandPanel {
|
||||
@@ -496,28 +492,28 @@ class LoginWindow(QMainWindow):
|
||||
}
|
||||
QFrame#LoginCard {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E1E6F0;
|
||||
border: 1px solid #EDEDEE;
|
||||
border-radius: 20px;
|
||||
}
|
||||
QFrame#LoginCard QFrame#SubtleCard {
|
||||
background-color: #F8FAFF;
|
||||
border: 1px solid #DCE2EF;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E4E4E5;
|
||||
border-radius: 12px;
|
||||
}
|
||||
QFrame#LoginCard QLabel { color: #17264B; background: transparent; }
|
||||
QFrame#LoginCard QLabel[role="muted"] { color: #7382A5; }
|
||||
QFrame#LoginCard QLabel { color: #1A1C1F; background: transparent; }
|
||||
QFrame#LoginCard QLabel[role="muted"] { color: #606163; }
|
||||
QFrame#LoginCard QLabel[role="danger"] { color: #C43E55; }
|
||||
QFrame#LoginCard QCheckBox#AllowSelfSignedCertificate { color: #9A6813; }
|
||||
QFrame#LoginCard QLineEdit,
|
||||
QFrame#LoginCard QSpinBox {
|
||||
color: #17264B;
|
||||
color: #1A1C1F;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #D6DEED;
|
||||
border: 1px solid #E4E4E5;
|
||||
border-radius: 12px;
|
||||
padding: 0 16px;
|
||||
font-size: 17px;
|
||||
selection-background-color: #E5E9FF;
|
||||
selection-color: #17264B;
|
||||
selection-background-color: #EEF1FA;
|
||||
selection-color: #1A1C1F;
|
||||
}
|
||||
QFrame#LoginCard QLineEdit#AccountEdit {
|
||||
min-height: 52px;
|
||||
@@ -525,9 +521,9 @@ class LoginWindow(QMainWindow):
|
||||
padding-left: 52px;
|
||||
}
|
||||
QFrame#LoginCard QLineEdit:hover,
|
||||
QFrame#LoginCard QSpinBox:hover { border-color: #9AA8FF; }
|
||||
QFrame#LoginCard QSpinBox:hover { border-color: #8B9AD9; }
|
||||
QFrame#LoginCard QLineEdit:focus,
|
||||
QFrame#LoginCard QSpinBox:focus { border: 1.5px solid #7080F7; }
|
||||
QFrame#LoginCard QSpinBox:focus { border: 1.5px solid #8B9AD9; }
|
||||
QFrame#LoginCard QLineEdit#PasswordEdit {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
@@ -537,31 +533,31 @@ class LoginWindow(QMainWindow):
|
||||
QFrame#LoginCard QCheckBox#DemoModeCheck { spacing: 10px; }
|
||||
QFrame#PasswordField {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #D6DEED;
|
||||
border: 1px solid #E4E4E5;
|
||||
border-radius: 12px;
|
||||
}
|
||||
QFrame#PasswordField:focus-within { border-color: #7080F7; }
|
||||
QFrame#PasswordField:focus-within { border-color: #8B9AD9; }
|
||||
QFrame#LoginCard QCheckBox {
|
||||
color: #6F7FA3;
|
||||
color: #606163;
|
||||
spacing: 13px;
|
||||
font-size: 16px;
|
||||
}
|
||||
QFrame#LoginCard QCheckBox::indicator {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid #CFD8EB;
|
||||
border: 1px solid #E4E4E5;
|
||||
border-radius: 6px;
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
QFrame#LoginCard QCheckBox::indicator:hover { border-color: #7A8AF8; }
|
||||
QFrame#LoginCard QCheckBox::indicator:hover { border-color: #8B9AD9; }
|
||||
QFrame#LoginCard QCheckBox::indicator:checked {
|
||||
border-color: #6475F5;
|
||||
background-color: #6475F5;
|
||||
border-color: #4F63D9;
|
||||
background-color: #4F63D9;
|
||||
}
|
||||
QFrame#LoginCard QToolButton#PasswordReveal {
|
||||
min-width: 87px;
|
||||
max-width: 87px;
|
||||
color: #8290B0;
|
||||
color: #6A6B6D;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
@@ -570,14 +566,11 @@ class LoginWindow(QMainWindow):
|
||||
min-height: 58px;
|
||||
max-height: 58px;
|
||||
color: #FFFFFF;
|
||||
background: qlineargradient(
|
||||
x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #5B6BF1, stop:0.55 #6675FA, stop:1 #5865F2
|
||||
);
|
||||
background-color: #4F63D9;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
}
|
||||
QFrame#LoginCard QPushButton#ServerSettingsToggle {
|
||||
min-height: 56px;
|
||||
@@ -585,17 +578,17 @@ class LoginWindow(QMainWindow):
|
||||
padding: 0;
|
||||
}
|
||||
QFrame#LoginCard QPushButton[variant="primary"]:hover {
|
||||
background-color: #5262ED;
|
||||
background-color: #4156C4;
|
||||
}
|
||||
QFrame#LoginCard QPushButton[variant="secondary"] {
|
||||
color: #4353BD;
|
||||
background-color: #EDF0FF;
|
||||
border: 1px solid #D3DAFC;
|
||||
color: #1A1C1F;
|
||||
background-color: #F0F0F0;
|
||||
border: 1px solid #E4E4E5;
|
||||
border-radius: 9px;
|
||||
}
|
||||
QFrame#LoginCard QPushButton[variant="secondary"]:hover {
|
||||
background-color: #DCE3FF;
|
||||
border-color: #8B98F8;
|
||||
background-color: #E4E4E5;
|
||||
border-color: #8B9AD9;
|
||||
}
|
||||
QScrollArea#LoginAreaScroll,
|
||||
QScrollArea#LoginAreaScroll > QWidget > QWidget {
|
||||
@@ -629,6 +622,12 @@ class LoginWindow(QMainWindow):
|
||||
spacing = 20
|
||||
self.login_root.setContentsMargins(*margins)
|
||||
self.login_root.setSpacing(spacing)
|
||||
# Keep the form fully visible when the 60/40 desktop split would
|
||||
# otherwise crop its fixed-width card. Small windows focus on login.
|
||||
self.brand_panel.setVisible(width >= 1120)
|
||||
self.login_area_layout.setContentsMargins(
|
||||
0, min(104, max(24, (event.size().height() - 694) // 2)), 0, 12
|
||||
)
|
||||
super().resizeEvent(event)
|
||||
|
||||
def _build_brand_panel(self) -> QWidget:
|
||||
@@ -647,20 +646,19 @@ class LoginWindow(QMainWindow):
|
||||
area.setFrameShape(QFrame.Shape.NoFrame)
|
||||
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
area.setMinimumWidth(492)
|
||||
content = QWidget()
|
||||
content.setObjectName("LoginAreaContent")
|
||||
area.setWidget(content)
|
||||
self.login_scroll = area
|
||||
outer = QVBoxLayout(content)
|
||||
# The supplied 1536×1024 capture contains a 60 px native title bar.
|
||||
# Its card begins at y=198, i.e. y=138 in the 1536×964 client area.
|
||||
# The root starts at y=34, so the deterministic lead inset is 104 px.
|
||||
outer.setContentsMargins(0, 104, 0, 0)
|
||||
self.login_area_layout = outer
|
||||
outer.setContentsMargins(0, 40, 0, 12)
|
||||
|
||||
self.card = QFrame()
|
||||
self.card.setObjectName("LoginCard")
|
||||
self.card.setFixedWidth(480)
|
||||
self.card.setMinimumHeight(694)
|
||||
self.card.setMinimumHeight(620)
|
||||
self.card.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum)
|
||||
card_shadow = QGraphicsDropShadowEffect(self.card)
|
||||
card_shadow.setBlurRadius(38)
|
||||
@@ -675,13 +673,13 @@ class LoginWindow(QMainWindow):
|
||||
|
||||
title = QLabel("欢迎回来")
|
||||
title.setObjectName("LoginTitle")
|
||||
title.setStyleSheet("color:#14224A; font-size:33px; font-weight:700;")
|
||||
title.setStyleSheet("color:#1A1C1F; font-size:30px; font-weight:500;")
|
||||
title.setContentsMargins(1, -3, 0, 3)
|
||||
title.setFixedHeight(46)
|
||||
card_layout.addWidget(title)
|
||||
subtitle = QLabel("使用医生账号登录工作站")
|
||||
subtitle.setProperty("role", "muted")
|
||||
subtitle.setStyleSheet("color:#7382A5; font-size:18px;")
|
||||
subtitle.setStyleSheet("color:#606163; font-size:18px;")
|
||||
subtitle.setContentsMargins(1, 8, 0, 0)
|
||||
subtitle.setFixedHeight(27)
|
||||
card_layout.addWidget(subtitle)
|
||||
@@ -691,7 +689,7 @@ class LoginWindow(QMainWindow):
|
||||
card_layout.addWidget(self.error_banner)
|
||||
|
||||
account_label = QLabel("账号")
|
||||
account_label.setStyleSheet("color:#17264B; font-size:18px; font-weight:600;")
|
||||
account_label.setStyleSheet("color:#1A1C1F; font-size:16px; font-weight:500;")
|
||||
account_label.setContentsMargins(0, -2, 0, 2)
|
||||
account_label.setFixedHeight(24)
|
||||
card_layout.addWidget(account_label)
|
||||
@@ -707,7 +705,7 @@ class LoginWindow(QMainWindow):
|
||||
card_layout.addSpacing(21)
|
||||
|
||||
password_label = QLabel("密码")
|
||||
password_label.setStyleSheet("color:#17264B; font-size:18px; font-weight:600;")
|
||||
password_label.setStyleSheet("color:#1A1C1F; font-size:16px; font-weight:500;")
|
||||
password_label.setContentsMargins(0, -3, 0, 3)
|
||||
password_label.setFixedHeight(24)
|
||||
card_layout.addWidget(password_label)
|
||||
@@ -777,16 +775,16 @@ class LoginWindow(QMainWindow):
|
||||
divider.setSpacing(18)
|
||||
line_left = QFrame()
|
||||
line_left.setFrameShape(QFrame.Shape.HLine)
|
||||
line_left.setStyleSheet("color:#DFE4F0; background:#DFE4F0; max-height:1px;")
|
||||
line_left.setStyleSheet("color:#EDEDEE; background:#EDEDEE; max-height:1px;")
|
||||
divider.addWidget(line_left, 1)
|
||||
divider_text = QLabel("或")
|
||||
divider_text.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
divider_text.setStyleSheet("color:#7C89A8; font-size:16px;")
|
||||
divider_text.setStyleSheet("color:#6A6B6D; font-size:16px;")
|
||||
divider_text.setFixedSize(38, 22)
|
||||
divider.addWidget(divider_text)
|
||||
line_right = QFrame()
|
||||
line_right.setFrameShape(QFrame.Shape.HLine)
|
||||
line_right.setStyleSheet("color:#DFE4F0; background:#DFE4F0; max-height:1px;")
|
||||
line_right.setStyleSheet("color:#EDEDEE; background:#EDEDEE; max-height:1px;")
|
||||
divider.addWidget(line_right, 1)
|
||||
debug_settings_layout.addLayout(divider)
|
||||
debug_settings_layout.addSpacing(20)
|
||||
@@ -873,7 +871,7 @@ class LoginWindow(QMainWindow):
|
||||
footnote_row.addWidget(lock)
|
||||
footnote = QLabel("登录即表示你同意遵守机构的数据安全与隐私规范。")
|
||||
footnote.setProperty("role", "muted")
|
||||
footnote.setStyleSheet("color:#7A89AA; font-size:15px;")
|
||||
footnote.setStyleSheet("color:#6A6B6D; font-size:15px;")
|
||||
footnote.setContentsMargins(0, -4, 0, 4)
|
||||
footnote.setWordWrap(True)
|
||||
footnote.setFixedHeight(40)
|
||||
@@ -882,12 +880,12 @@ class LoginWindow(QMainWindow):
|
||||
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.setStyleSheet("color:#6A6B6D; 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
|
||||
self.card, 0, Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop
|
||||
)
|
||||
outer.addStretch(1)
|
||||
self.busy_overlay = BusyOverlay(self.card, "正在验证账号…")
|
||||
@@ -899,28 +897,11 @@ class LoginWindow(QMainWindow):
|
||||
|
||||
@staticmethod
|
||||
def _account_icon() -> QIcon:
|
||||
pixmap = crisp_pixmap(24)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(_round_pen("#8292B6", 2))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawEllipse(QPointF(12, 7.5), 4.2, 4.2)
|
||||
painter.drawRoundedRect(QRectF(4.5, 14, 15, 7), 3.5, 3.5)
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
return icons.icon("user", "muted", 24)
|
||||
|
||||
@staticmethod
|
||||
def _lock_icon() -> QIcon:
|
||||
pixmap = crisp_pixmap(20)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(_round_pen("#8FA0C4", 1.6))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawRoundedRect(QRectF(5, 8, 10, 9), 2, 2)
|
||||
painter.drawArc(QRectF(7, 3, 6, 9), 0, 180 * 16)
|
||||
painter.drawLine(QPointF(10, 11), QPointF(10, 14))
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
return icons.icon("lock", "muted", 20)
|
||||
|
||||
def _restore_settings(self) -> None:
|
||||
configured_account = getattr(self.config, "remembered_account", "")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,4 @@
|
||||
"""Application-wide visual system for the AI consultation workstation.
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Shared typography and neutral reading surfaces for the clinical workspace."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -17,12 +8,15 @@ from pathlib import Path
|
||||
from string import Template
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, Qt
|
||||
from PySide6.QtCore import QEvent, QObject, QPointF, Qt
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QFont,
|
||||
QFontDatabase,
|
||||
QPainter,
|
||||
QPainterPath,
|
||||
QPalette,
|
||||
QPen,
|
||||
QPixmap,
|
||||
QTextBlockFormat,
|
||||
QTextCharFormat,
|
||||
@@ -35,50 +29,57 @@ from PySide6.QtWidgets import (
|
||||
QFileDialog,
|
||||
QInputDialog,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QScrollArea,
|
||||
QTextBrowser,
|
||||
QTextEdit,
|
||||
)
|
||||
|
||||
# Canonical semantic tokens. The legacy teal/ink aliases remain available to
|
||||
# callers while the stylesheet itself is generated from this mapping.
|
||||
from doctor_workstation.resources import resource_path
|
||||
|
||||
# ``motion`` deliberately imports nothing from this module, so this stays a
|
||||
# one-way dependency: tokens here, animation there.
|
||||
from . import motion
|
||||
|
||||
# Neutral reading colors follow the installed Codex light chrome defaults.
|
||||
# Brand accents are independent of reading ink; use opaque text for stable contrast.
|
||||
# The legacy teal/ink aliases remain for existing callers.
|
||||
COLORS = {
|
||||
# Sampled from the supplied 1710 x 920 product comps. The window edge is
|
||||
# the only blue-tinted surface; the application workspace itself is an
|
||||
# almost-white #FCFDFE field.
|
||||
"canvas": "#EEF3FD",
|
||||
"canvas_mid": "#F7F9FE",
|
||||
"canvas_glow": "#E5ECFD",
|
||||
"canvas": "#F4F6FA",
|
||||
"canvas_mid": "#FFFFFF",
|
||||
"canvas_glow": "#EEF1FA",
|
||||
"surface": "#FFFFFF",
|
||||
"surface_alt": "#FAFBFE",
|
||||
"raised": "#F5F7FC",
|
||||
"glass": "rgba(255, 255, 255, 252)",
|
||||
"glass_alt": "rgba(248, 250, 255, 252)",
|
||||
"line": "#E6EAF5",
|
||||
"line_soft": "rgba(82, 97, 246, 40)",
|
||||
"text": "#111F46",
|
||||
"text_soft": "#3F4E75",
|
||||
"muted": "#7886AA",
|
||||
"disabled_surface": "#F0F2F8",
|
||||
"disabled_text": "#A4ADC3",
|
||||
"indigo": "#5761F4",
|
||||
"indigo_hover": "#4C57E9",
|
||||
"indigo_pressed": "#4451E2",
|
||||
"indigo_pale": "#F0F2FF",
|
||||
"focus": "#8D9BFF",
|
||||
"selection": "#EDF0FF",
|
||||
"success": "#17A77D",
|
||||
"success_pale": "#EAF9F3",
|
||||
"warning": "#D38625",
|
||||
"warning_pale": "#FFF5E6",
|
||||
"danger": "#F15B67",
|
||||
"danger_pale": "#FFF1F3",
|
||||
"info": "#4D69ED",
|
||||
"info_pale": "#F0F4FF",
|
||||
"surface_alt": "#F7F7F7",
|
||||
"raised": "#F7F7F7",
|
||||
"glass": "#FFFFFF",
|
||||
"glass_alt": "#F7F7F7",
|
||||
"line": "#EDEDEE",
|
||||
"line_soft": "#E4E4E5",
|
||||
"text": "#1A1C1F",
|
||||
"text_soft": "#606163",
|
||||
"muted": "#6A6B6D",
|
||||
"disabled_surface": "#F2F2F2",
|
||||
"disabled_text": "#8E8F90",
|
||||
"indigo": "#4F63D9",
|
||||
"indigo_hover": "#4156C4",
|
||||
"indigo_pressed": "#354BB4",
|
||||
"indigo_pale": "#EEF1FA",
|
||||
"focus": "#8B9AD9",
|
||||
"selection": "#EEF1FA",
|
||||
"success": "#287B65",
|
||||
"success_pale": "#EEF7F3",
|
||||
"warning": "#A9691D",
|
||||
"warning_pale": "#FCF5E9",
|
||||
"danger": "#BE4B58",
|
||||
"danger_pale": "#FCF0F2",
|
||||
"info": "#4F63D9",
|
||||
"info_pale": "#EEF1FA",
|
||||
# Backward-compatible names used by older UI code and integrations.
|
||||
"ink": "#111F46",
|
||||
"ink_soft": "#3F4E75",
|
||||
"teal": "#5761F4",
|
||||
"teal_dark": "#4451E2",
|
||||
"teal_pale": "#F0F2FF",
|
||||
"ink": "#1A1C1F",
|
||||
"ink_soft": "#606163",
|
||||
"teal": "#4F63D9",
|
||||
"teal_dark": "#354BB4",
|
||||
"teal_pale": "#EEF1FA",
|
||||
}
|
||||
|
||||
|
||||
@@ -87,32 +88,47 @@ COLORS = {
|
||||
# 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_caption": "13px", # table headers, hints, badges, timestamps
|
||||
"fs_body": "14px", # default UI text
|
||||
"fs_strong": "15px", # 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
|
||||
}
|
||||
|
||||
# The bundled variable font supplies real Regular, Medium and Semibold faces.
|
||||
# Keep ordinary controls at Medium and reserve Semibold for headings.
|
||||
WEIGHTS = {
|
||||
"fw_body": "400",
|
||||
"fw_control": "500",
|
||||
"fw_heading": "600",
|
||||
}
|
||||
|
||||
# --- 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.
|
||||
# Controls leave room for the 14px reading size without increasing table density.
|
||||
METRICS = {
|
||||
"h_compact": "28px", # inline row actions, chips, links
|
||||
"h_default": "32px", # buttons, inputs, combos, tabs
|
||||
"h_default": "34px", # 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",
|
||||
# Corner radii. These existed before but the pages invented their own, so
|
||||
# the same role - a card - shipped at 10, 11, 12, 13, 14 and 16 px across
|
||||
# six pages. Nothing in a product is a "10px card"; it is either a card or
|
||||
# it is not, and it should round like every other card next to it.
|
||||
#
|
||||
# Nesting rule: an element sitting flush inside a rounded container takes
|
||||
# ``outer - gap``. Where the gap is bigger than the outer radius the inner
|
||||
# element is far enough from the corner that its own radius is free.
|
||||
"r_xs": "4px", # chips, badges, tiny status pills
|
||||
"r_sm": "6px", # inline row actions, tags
|
||||
"r_md": "8px", # buttons, inputs, combo boxes
|
||||
"r_lg": "12px", # cards, filter bars, panels
|
||||
"r_xl": "16px", # the shell surfaces the cards sit on
|
||||
"pad_control": "12px", # horizontal padding inside default controls
|
||||
"pad_compact": "9px",
|
||||
}
|
||||
|
||||
_QSS_TOKENS = {**COLORS, **TYPE, **METRICS}
|
||||
_QSS_TOKENS = {**COLORS, **TYPE, **WEIGHTS, **METRICS}
|
||||
|
||||
|
||||
def crisp_pixmap(width: int, height: int | None = None) -> QPixmap:
|
||||
@@ -144,8 +160,8 @@ GLOBAL_QSS = Template(
|
||||
QWidget {
|
||||
color: $text;
|
||||
background-color: transparent;
|
||||
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
||||
font-size: $fs_body;
|
||||
font-weight: $fw_body;
|
||||
}
|
||||
|
||||
QMainWindow, QDialog, QWidget#LoginCanvas {
|
||||
@@ -170,7 +186,7 @@ QDialog[businessDialog="true"] QFrame[dialogRole="header"] {
|
||||
QDialog[businessDialog="true"] QLabel[dialogRole="title"] {
|
||||
color: $text;
|
||||
font-size: $fs_section;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QDialog[businessDialog="true"] QLabel[dialogRole="subtitle"] {
|
||||
color: $muted;
|
||||
@@ -214,48 +230,43 @@ QInputDialog QLabel {
|
||||
font-size: $fs_strong;
|
||||
}
|
||||
QWidget#AppCanvas {
|
||||
background-color: qlineargradient(
|
||||
x1: 0, y1: 0, x2: 1, y2: 1,
|
||||
stop: 0 $canvas,
|
||||
stop: 0.58 $canvas_mid,
|
||||
stop: 1 $canvas_glow
|
||||
);
|
||||
background-color: $canvas;
|
||||
border-radius: 18px;
|
||||
}
|
||||
QWidget#ShellWorkspace, QStackedWidget#ShellPageStack {
|
||||
background-color: #FCFDFE;
|
||||
background-color: $canvas_mid;
|
||||
}
|
||||
|
||||
QLabel[role="muted"] { color: $muted; }
|
||||
QLabel[role="danger"] { color: $danger; }
|
||||
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="breadcrumbSeparator"] { color: #8E8F90; font-size: $fs_strong; }
|
||||
QLabel[role="breadcrumbCurrent"] { color: $text_soft; font-size: $fs_caption; font-weight: $fw_control; }
|
||||
QLabel[role="eyebrow"] {
|
||||
color: $indigo_hover;
|
||||
font-size: $fs_caption;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_control;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
QLabel[role="pageTitle"] {
|
||||
color: $text;
|
||||
font-size: $fs_title;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_heading;
|
||||
}
|
||||
QLabel[role="sectionTitle"] {
|
||||
color: $text;
|
||||
font-size: $fs_section;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QLabel[role="display"] {
|
||||
color: $text;
|
||||
font-size: $fs_display;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_heading;
|
||||
}
|
||||
QLabel[role="metric"] {
|
||||
color: $text;
|
||||
font-size: $fs_title;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_heading;
|
||||
}
|
||||
|
||||
QFrame#Card, QFrame#Panel, QFrame#FilterBar, QFrame#DetailPanel,
|
||||
@@ -277,19 +288,19 @@ QFrame#MetricCard {
|
||||
}
|
||||
QFrame#MetricCard:hover { border-color: $line_soft; background-color: $surface_alt; }
|
||||
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="metricValue"] { color: $text; font-size: $fs_title; font-weight: $fw_heading; }
|
||||
QFrame#MetricCard QLabel[role="metricHint"] { color: $muted; font-size: $fs_caption; }
|
||||
QFrame#ReceptionDetailPanel { background-color: transparent; border: 0; }
|
||||
QFrame#ReceptionAiCard {
|
||||
min-height: 132px;
|
||||
background-color: #F8FAFF;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid $line_soft;
|
||||
border-radius: 13px;
|
||||
}
|
||||
QLabel#ReceptionAiTitle {
|
||||
color: $indigo_pressed;
|
||||
font-size: $fs_strong;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QFrame#ReceptionAiCard QPushButton[variant="secondary"] {
|
||||
min-height: $h_compact;
|
||||
@@ -302,7 +313,7 @@ QLabel#MetricGlyph {
|
||||
border: 1px solid $line_soft;
|
||||
border-radius: 11px;
|
||||
font-size: $fs_section;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_heading;
|
||||
}
|
||||
QLabel#MetricGlyph[kind="success"] { color: $success; background-color: $success_pale; }
|
||||
QLabel#MetricGlyph[kind="warning"] { color: $warning; background-color: $warning_pale; }
|
||||
@@ -318,7 +329,7 @@ QGroupBox {
|
||||
border: 1px solid $line;
|
||||
border-radius: 12px;
|
||||
background-color: $glass;
|
||||
font-weight: 600;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
@@ -334,7 +345,7 @@ QPushButton {
|
||||
border-radius: $r_md;
|
||||
background-color: $surface;
|
||||
color: $text;
|
||||
font-weight: 600;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: $raised;
|
||||
@@ -345,7 +356,8 @@ QPushButton:pressed {
|
||||
border-color: $indigo_pressed;
|
||||
}
|
||||
QPushButton:focus {
|
||||
border: 2px solid $focus;
|
||||
border: 1px solid $indigo;
|
||||
background-color: $indigo_pale;
|
||||
}
|
||||
QPushButton:checked {
|
||||
color: #FFFFFF;
|
||||
@@ -361,10 +373,7 @@ QPushButton:disabled {
|
||||
|
||||
QPushButton[variant="primary"] {
|
||||
color: #FFFFFF;
|
||||
background-color: qlineargradient(
|
||||
x1: 0, y1: 0, x2: 1, y2: 0,
|
||||
stop: 0 $indigo, stop: 1 #7769F7
|
||||
);
|
||||
background-color: $indigo;
|
||||
border-color: $indigo;
|
||||
}
|
||||
QPushButton[variant="primary"]:hover,
|
||||
@@ -377,7 +386,10 @@ QPushButton[variant="primary"]:checked {
|
||||
background-color: $indigo_pressed;
|
||||
border-color: $indigo_pressed;
|
||||
}
|
||||
QPushButton[variant="primary"]:focus { border: 2px solid $focus; }
|
||||
QPushButton[variant="primary"]:focus {
|
||||
border: 1px solid $indigo_pressed;
|
||||
background-color: $indigo_hover;
|
||||
}
|
||||
QPushButton[variant="primary"]:disabled {
|
||||
color: $disabled_text;
|
||||
background-color: $surface_alt;
|
||||
@@ -410,7 +422,7 @@ QWidget#RowActions QPushButton[rowAction="true"] {
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $r_sm;
|
||||
font-weight: 600;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QWidget#RowActions QPushButton[rowAction="true"]:hover {
|
||||
color: $indigo_pressed;
|
||||
@@ -436,7 +448,7 @@ QToolButton#RowActionsMore {
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $r_sm;
|
||||
font-weight: 600;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QToolButton#RowActionsMore:hover {
|
||||
color: $text;
|
||||
@@ -457,7 +469,7 @@ QPushButton#NoteAttachmentPreview {
|
||||
background-color: $surface_alt;
|
||||
border: 1px solid $line;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QPushButton#NoteAttachmentPreview:hover {
|
||||
background-color: $indigo_pale;
|
||||
@@ -503,15 +515,15 @@ QPushButton[variant="success"] {
|
||||
background-color: $success;
|
||||
border-color: $success;
|
||||
}
|
||||
QPushButton[variant="success"]:hover { background-color: #65D4B7; border-color: #65D4B7; }
|
||||
QPushButton[variant="success"]:pressed { background-color: #319F84; border-color: #319F84; }
|
||||
QPushButton[variant="success"]:hover { background-color: #216A56; border-color: #216A56; }
|
||||
QPushButton[variant="success"]:pressed { background-color: #1B5746; border-color: #1B5746; }
|
||||
QPushButton[variant="warning"] {
|
||||
color: #FFFFFF;
|
||||
background-color: $warning;
|
||||
border-color: $warning;
|
||||
}
|
||||
QPushButton[variant="warning"]:hover { background-color: #F0C97C; border-color: #F0C97C; }
|
||||
QPushButton[variant="warning"]:pressed { background-color: #B99045; border-color: #B99045; }
|
||||
QPushButton[variant="warning"]:hover { background-color: #925B19; border-color: #925B19; }
|
||||
QPushButton[variant="warning"]:pressed { background-color: #794B14; border-color: #794B14; }
|
||||
|
||||
QPushButton[variant="ghost"] {
|
||||
color: $text_soft;
|
||||
@@ -536,7 +548,7 @@ QPushButton[variant="link"] {
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
QPushButton[variant="link"]:hover { color: $focus; background-color: $indigo_pale; }
|
||||
QPushButton[variant="link"]:hover { color: $indigo_hover; background-color: $indigo_pale; }
|
||||
QPushButton[variant="link"]:pressed { color: $indigo_hover; background-color: $surface_alt; }
|
||||
QPushButton[variant="chip"] {
|
||||
min-height: $h_compact;
|
||||
@@ -564,7 +576,7 @@ QPushButton[variant="nav"] {
|
||||
background-color: transparent;
|
||||
color: $muted;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QPushButton[variant="nav"]:hover { background-color: $surface_alt; color: $text; }
|
||||
QPushButton[variant="nav"]:pressed { background-color: $indigo_pale; }
|
||||
@@ -580,7 +592,7 @@ QToolButton {
|
||||
}
|
||||
QToolButton:hover { color: $text; background-color: $surface_alt; border-color: $line; }
|
||||
QToolButton:pressed { background-color: $indigo_pale; border-color: $indigo_pressed; }
|
||||
QToolButton:focus { border: 2px solid $focus; }
|
||||
QToolButton:focus { border: 1px solid $indigo; background-color: $indigo_pale; }
|
||||
QToolButton:checked { color: #FFFFFF; background-color: $indigo_pressed; border-color: $indigo_hover; }
|
||||
QToolButton:disabled { color: $disabled_text; background-color: transparent; border-color: transparent; }
|
||||
QToolButton[diagnosisChip="true"] {
|
||||
@@ -616,8 +628,8 @@ QDoubleSpinBox:hover, QKeySequenceEdit:hover { border-color: $indigo_hover; }
|
||||
QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus, QComboBox:focus,
|
||||
QDateEdit:focus, QDateTimeEdit:focus, QTimeEdit:focus, QSpinBox:focus,
|
||||
QDoubleSpinBox:focus, QKeySequenceEdit:focus {
|
||||
border: 2px solid $focus;
|
||||
background-color: $surface_alt;
|
||||
border: 1px solid $indigo;
|
||||
background-color: $surface;
|
||||
}
|
||||
QLineEdit:read-only, QTextEdit:read-only, QPlainTextEdit:read-only {
|
||||
color: $muted;
|
||||
@@ -676,7 +688,7 @@ QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {
|
||||
QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget, QTreeView {
|
||||
color: $text;
|
||||
background-color: $surface;
|
||||
alternate-background-color: #FBFCFF;
|
||||
alternate-background-color: $surface_alt;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
gridline-color: $line;
|
||||
@@ -684,7 +696,7 @@ QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget
|
||||
selection-color: $text;
|
||||
outline: 0;
|
||||
}
|
||||
QAbstractItemView:focus { border: 1px solid $indigo_hover; }
|
||||
QAbstractItemView:focus { border: 0; }
|
||||
QTableWidget::item, QTableView::item {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid $line;
|
||||
@@ -695,14 +707,14 @@ QTableWidget::item:selected, QTableView::item:selected {
|
||||
background-color: $selection;
|
||||
}
|
||||
QHeaderView::section {
|
||||
background-color: #F7F9FE;
|
||||
background-color: $raised;
|
||||
color: $muted;
|
||||
border: 0;
|
||||
border-right: 1px solid $line;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid $line;
|
||||
padding: 7px 8px;
|
||||
font-size: $fs_caption;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QHeaderView::section:hover { color: $text; background-color: $raised; }
|
||||
QTableCornerButton::section { background-color: $surface_alt; border: 0; }
|
||||
@@ -736,7 +748,7 @@ QTabBar::tab {
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
font-weight: 600;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QTabBar::tab:hover { color: $text; background-color: $surface_alt; }
|
||||
QTabBar::tab:focus { border-color: $focus; }
|
||||
@@ -788,8 +800,8 @@ QMenu::item {
|
||||
border-radius: $r_sm;
|
||||
background-color: transparent;
|
||||
}
|
||||
QMenu::item:selected { color: #FFFFFF; background-color: $indigo_pressed; }
|
||||
QMenu::item:pressed { background-color: $indigo; }
|
||||
QMenu::item:selected { color: $indigo_pressed; background-color: $indigo_pale; }
|
||||
QMenu::item:pressed { color: #FFFFFF; background-color: $indigo; }
|
||||
QMenu::item:disabled { color: $disabled_text; background-color: transparent; }
|
||||
QMenu::item[danger="true"] { color: $danger; }
|
||||
QMenu::item[danger="true"]:selected { color: $danger; background-color: $danger_pale; }
|
||||
@@ -841,8 +853,8 @@ QScrollBar::handle:vertical {
|
||||
min-height: $h_compact;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QScrollBar::handle:vertical:hover { background: $indigo_pressed; }
|
||||
QScrollBar::handle:vertical:pressed { background: $indigo; }
|
||||
QScrollBar::handle:vertical:hover { background: #A7B1C9; }
|
||||
QScrollBar::handle:vertical:pressed { background: #8894B2; }
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
|
||||
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; }
|
||||
QScrollBar:horizontal {
|
||||
@@ -855,8 +867,8 @@ QScrollBar::handle:horizontal {
|
||||
min-width: 30px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QScrollBar::handle:horizontal:hover { background: $indigo_pressed; }
|
||||
QScrollBar::handle:horizontal:pressed { background: $indigo; }
|
||||
QScrollBar::handle:horizontal:hover { background: #A7B1C9; }
|
||||
QScrollBar::handle:horizontal:pressed { background: #8894B2; }
|
||||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }
|
||||
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { background: transparent; }
|
||||
|
||||
@@ -885,7 +897,7 @@ QLabel#StatusBadge {
|
||||
border: 1px solid transparent;
|
||||
border-radius: $r_sm;
|
||||
font-size: $fs_caption;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QLabel#StatusBadge[kind="neutral"] { color: $muted; background-color: $surface_alt; border-color: $line; }
|
||||
QLabel#StatusBadge[kind="success"] { color: $success; background-color: $success_pale; border-color: rgba(73, 198, 165, 72); }
|
||||
@@ -902,7 +914,7 @@ QWidget#Pager QLabel#PagerActive {
|
||||
border: 1px solid $indigo;
|
||||
border-radius: 8px;
|
||||
font-size: $fs_caption;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QWidget#Pager QPushButton {
|
||||
min-height: $h_compact;
|
||||
@@ -918,7 +930,7 @@ QLabel#EmptyStateGlyph {
|
||||
border: 1px solid $line_soft;
|
||||
border-radius: 22px;
|
||||
font-size: $fs_display;
|
||||
font-weight: 500;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
|
||||
QFrame#MessageBanner {
|
||||
@@ -927,7 +939,7 @@ QFrame#MessageBanner {
|
||||
color: $text_soft;
|
||||
}
|
||||
QFrame#MessageBanner QLabel { background-color: transparent; }
|
||||
QFrame#MessageBanner QLabel#MessageBannerIcon { font-weight: 700; }
|
||||
QFrame#MessageBanner QLabel#MessageBannerIcon { font-weight: $fw_heading; }
|
||||
QFrame#MessageBanner[kind="info"] { color: $info; background-color: $info_pale; border-color: rgba(120, 167, 255, 82); }
|
||||
QFrame#MessageBanner[kind="success"] { color: $success; background-color: $success_pale; border-color: rgba(73, 198, 165, 82); }
|
||||
QFrame#MessageBanner[kind="warning"] { color: $warning; background-color: $warning_pale; border-color: rgba(228, 185, 103, 82); }
|
||||
@@ -943,7 +955,7 @@ QLabel#Toast {
|
||||
border: 1px solid $line_soft;
|
||||
border-radius: 11px;
|
||||
padding: 11px 16px;
|
||||
font-weight: 600;
|
||||
font-weight: $fw_control;
|
||||
}
|
||||
QLabel#Toast[kind="info"] { color: $info; background-color: $info_pale; border-color: rgba(120, 167, 255, 96); }
|
||||
QLabel#Toast[kind="success"] { color: $success; background-color: $success_pale; border-color: rgba(73, 198, 165, 96); }
|
||||
@@ -978,7 +990,7 @@ QLabel#UserAvatar {
|
||||
border: 1px solid $line_soft;
|
||||
border-radius: 18px;
|
||||
font-size: $fs_strong;
|
||||
font-weight: 700;
|
||||
font-weight: $fw_heading;
|
||||
}
|
||||
QWidget#LoginBrandPanel {
|
||||
background-color: qlineargradient(
|
||||
@@ -997,6 +1009,29 @@ QFrame#LoginCard {
|
||||
|
||||
QSplitter::handle { background-color: transparent; width: 8px; height: 8px; }
|
||||
QSplitter::handle:hover { background-color: $indigo_pressed; }
|
||||
QPushButton[variant="secondary"]:disabled,
|
||||
QPushButton[variant="secondary"]:checked:disabled,
|
||||
QPushButton[variant="success"]:disabled,
|
||||
QPushButton[variant="success"]:checked:disabled,
|
||||
QPushButton[variant="warning"]:disabled,
|
||||
QPushButton[variant="warning"]:checked:disabled,
|
||||
QPushButton[variant="danger"]:disabled,
|
||||
QPushButton[variant="danger"]:checked:disabled,
|
||||
QPushButton[variant="dangerGhost"]:disabled,
|
||||
QPushButton[variant="dangerGhost"]:checked:disabled,
|
||||
QPushButton[variant="ghost"]:disabled,
|
||||
QPushButton[variant="ghost"]:checked:disabled,
|
||||
QPushButton[variant="link"]:disabled,
|
||||
QPushButton[variant="link"]:checked:disabled,
|
||||
QPushButton[variant="chip"]:disabled,
|
||||
QPushButton[variant="chip"]:checked:disabled,
|
||||
QPushButton[variant="nav"]:disabled,
|
||||
QPushButton[variant="nav"]:checked:disabled {
|
||||
color: $disabled_text;
|
||||
background-color: $disabled_surface;
|
||||
border-color: $line;
|
||||
}
|
||||
|
||||
QToolTip {
|
||||
color: $text;
|
||||
background-color: $raised;
|
||||
@@ -1024,6 +1059,166 @@ _BLOCK_RHYTHM = {
|
||||
}
|
||||
|
||||
|
||||
# --- Surfaces -------------------------------------------------------------
|
||||
# There is deliberately no drop-shadow system here. One was tried: a painted
|
||||
# layer behind each page's cards, on the theory that flat outlines were what
|
||||
# made the workspace look unfinished. It does not work in this palette. The
|
||||
# cards are #FFFFFF sitting on a #FFFFFF workspace - barely one percent apart -
|
||||
# so a shadow has no tonal room to read as depth and instead composites into a
|
||||
# neutral grey rim around every card, which against the blue-tinted ground looks
|
||||
# like a dirty second border rather than elevation.
|
||||
#
|
||||
# Depth in this product comes from the border and the fill, not from shadow.
|
||||
|
||||
# --- Control indicator glyphs --------------------------------------------
|
||||
# Styling ``QCheckBox::indicator`` with a background and border but no image
|
||||
# tells Qt to stop drawing its own tick, so every checked box in the product
|
||||
# rendered as a plain indigo square and the partially-checked state was an
|
||||
# unlabelled grey one. Selection columns on the prescription, patient and
|
||||
# diagnosis tables all depend on that tick, so the marks are painted here and
|
||||
# handed back to the stylesheet as cached PNGs.
|
||||
|
||||
_INDICATOR_BOX = 15
|
||||
_INDICATOR_REVISION = "1"
|
||||
|
||||
|
||||
#: The dropdown caret is the one glyph Fusion still drew itself - a solid
|
||||
#: triangle sitting beside an otherwise entirely stroked icon set. Rendering it
|
||||
#: here brings every QComboBox, date field and tool-button menu onto the same
|
||||
#: 24-unit grid as the rest of the product.
|
||||
_CARET_BOX = 12
|
||||
|
||||
|
||||
def _indicator_pixmap(kind: str, color: str, ratio: int, box_size: int | None = None) -> QPixmap:
|
||||
box = (box_size or _INDICATOR_BOX) * ratio
|
||||
pixmap = QPixmap(box, box)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
try:
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
||||
scale = box / 24.0
|
||||
painter.scale(scale, scale)
|
||||
pen = QPen(QColor(color), 3.4)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
if kind == "check":
|
||||
path = QPainterPath(QPointF(5.0, 12.5))
|
||||
path.lineTo(QPointF(10.0, 17.5))
|
||||
path.lineTo(QPointF(19.0, 6.5))
|
||||
painter.drawPath(path)
|
||||
elif kind == "dash":
|
||||
painter.drawLine(QPointF(6.0, 12.0), QPointF(18.0, 12.0))
|
||||
elif kind == "dot":
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor(color))
|
||||
painter.drawEllipse(QPointF(12.0, 12.0), 4.6, 4.6)
|
||||
elif kind == "caret":
|
||||
pen.setWidthF(2.6)
|
||||
painter.setPen(pen)
|
||||
path = QPainterPath(QPointF(6.0, 9.5))
|
||||
path.lineTo(QPointF(12.0, 15.5))
|
||||
path.lineTo(QPointF(18.0, 9.5))
|
||||
painter.drawPath(path)
|
||||
finally:
|
||||
painter.end()
|
||||
return pixmap
|
||||
|
||||
|
||||
def _indicator_asset_dir() -> Path | None:
|
||||
try:
|
||||
from platformdirs import user_cache_dir
|
||||
|
||||
from ..config import APP_AUTHOR, APP_NAME
|
||||
|
||||
directory = Path(user_cache_dir(APP_NAME, APP_AUTHOR)) / "indicators"
|
||||
except Exception: # pragma: no cover - falls back to the user home
|
||||
directory = Path.home() / ".zhenyangdoctor" / "indicators"
|
||||
try:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
except OSError: # pragma: no cover - read-only deployment
|
||||
return None
|
||||
return directory
|
||||
|
||||
|
||||
def _indicator_url(kind: str, color: str, box_size: int | None = None) -> str | None:
|
||||
"""Return a stylesheet ``url()`` body for one indicator mark, or None."""
|
||||
|
||||
directory = _indicator_asset_dir()
|
||||
if directory is None:
|
||||
return None
|
||||
stem = f"{kind}-{color.lstrip('#').lower()}-{box_size or _INDICATOR_BOX}-{_INDICATOR_REVISION}"
|
||||
base = directory / f"{stem}.png"
|
||||
# Qt resolves the ``@2x`` companion itself on scaled displays, which keeps
|
||||
# the mark crisp at the 125%/150% factors clinic workstations run at.
|
||||
retina = directory / f"{stem}@2x.png"
|
||||
try:
|
||||
if not base.exists() and not _indicator_pixmap(kind, color, 1, box_size).save(
|
||||
str(base), "PNG"
|
||||
):
|
||||
return None
|
||||
if not retina.exists():
|
||||
_indicator_pixmap(kind, color, 2, box_size).save(str(retina), "PNG")
|
||||
except OSError: # pragma: no cover - read-only deployment
|
||||
return None
|
||||
return base.as_posix()
|
||||
|
||||
|
||||
def _indicator_qss() -> str:
|
||||
"""Stylesheet fragment that restores the tick, dash and radio dot."""
|
||||
|
||||
marks = {
|
||||
"check": _indicator_url("check", "#FFFFFF"),
|
||||
"check_disabled": _indicator_url("check", COLORS["disabled_text"]),
|
||||
"dash": _indicator_url("dash", "#FFFFFF"),
|
||||
"dot": _indicator_url("dot", "#FFFFFF"),
|
||||
"dot_disabled": _indicator_url("dot", COLORS["disabled_text"]),
|
||||
"caret": _indicator_url("caret", COLORS["muted"], _CARET_BOX),
|
||||
"caret_disabled": _indicator_url("caret", COLORS["disabled_text"], _CARET_BOX),
|
||||
}
|
||||
if any(value is None for value in marks.values()):
|
||||
return ""
|
||||
return f"""
|
||||
QCheckBox::indicator:checked {{ image: url({marks["check"]}); }}
|
||||
QCheckBox::indicator:indeterminate {{
|
||||
background-color: {COLORS["indigo"]};
|
||||
border: 1px solid {COLORS["indigo"]};
|
||||
border-radius: 4px;
|
||||
image: url({marks["dash"]});
|
||||
}}
|
||||
QCheckBox::indicator:checked:disabled {{ image: url({marks["check_disabled"]}); }}
|
||||
QRadioButton::indicator:checked {{ image: url({marks["dot"]}); }}
|
||||
QRadioButton::indicator:checked:disabled {{ image: url({marks["dot_disabled"]}); }}
|
||||
QComboBox::down-arrow, QDateEdit::down-arrow, QDateTimeEdit::down-arrow,
|
||||
QTimeEdit::down-arrow {{
|
||||
width: {_CARET_BOX}px;
|
||||
height: {_CARET_BOX}px;
|
||||
image: url({marks["caret"]});
|
||||
}}
|
||||
/* A tool button's menu indicator defaults to the bottom-right corner, which was
|
||||
invisible while it was Fusion's 6 px triangle and became obvious once it was a
|
||||
12 px chevron - the caret dropped below the label instead of sitting beside
|
||||
it. Anchor it to the right edge; the buttons that show one already reserve
|
||||
right padding for it, so nothing here changes their metrics. */
|
||||
QToolButton::menu-indicator {{
|
||||
width: {_CARET_BOX}px;
|
||||
height: {_CARET_BOX}px;
|
||||
image: url({marks["caret"]});
|
||||
subcontrol-origin: padding;
|
||||
subcontrol-position: right center;
|
||||
right: 6px;
|
||||
}}
|
||||
/* Re-assert the suppressions that the block above would otherwise override.
|
||||
These live here rather than in the main sheet because this fragment is
|
||||
appended after it, and a later rule wins in Qt when specificity ties. */
|
||||
QToolButton#RowActionsMore::menu-indicator {{ width: 0; height: 0; image: none; }}
|
||||
QComboBox::down-arrow:disabled, QDateEdit::down-arrow:disabled,
|
||||
QDateTimeEdit::down-arrow:disabled, QTimeEdit::down-arrow:disabled,
|
||||
QToolButton::menu-indicator:disabled {{ image: url({marks["caret_disabled"]}); }}
|
||||
"""
|
||||
|
||||
|
||||
def _block_is_all_bold(block: Any) -> bool:
|
||||
"""True when every visible run in the block is bold.
|
||||
|
||||
@@ -1100,7 +1295,7 @@ def apply_reading_rhythm(browser: QTextBrowser, *, role: str) -> None:
|
||||
char_format = QTextCharFormat()
|
||||
char_format.setFont(heading_font)
|
||||
if role != "doctor":
|
||||
char_format.setForeground(QColor("#111B3F"))
|
||||
char_format.setForeground(QColor("#1A1C1F"))
|
||||
cursor.setPosition(block.position())
|
||||
cursor.setPosition(
|
||||
block.position() + block.length() - 1,
|
||||
@@ -1123,14 +1318,21 @@ def _apply_group(
|
||||
|
||||
|
||||
def _register_preferred_cjk_fonts() -> str:
|
||||
"""Make the bundled/offscreen Windows runtime aware of its CJK fonts.
|
||||
"""Prefer the shipped outline font, with native CJK faces as a fallback."""
|
||||
|
||||
Qt's offscreen platform does not always enumerate the Windows font
|
||||
collection. Registering the already-installed YaHei collection only
|
||||
when it is missing prevents Chinese text from degrading to tofu boxes in
|
||||
packaged captures and headless visual checks. Other platforms continue
|
||||
to use their native PingFang/Noto fallback.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
registered = getattr(app, "_doctor_bundled_font_family", None)
|
||||
if registered:
|
||||
return registered
|
||||
bundled_font = resource_path("fonts", "NotoSansSC-VF.ttf")
|
||||
if bundled_font.is_file():
|
||||
font_id = QFontDatabase.addApplicationFont(str(bundled_font))
|
||||
families = QFontDatabase.applicationFontFamilies(font_id) if font_id >= 0 else []
|
||||
if families:
|
||||
family = families[0]
|
||||
if app is not None:
|
||||
app._doctor_bundled_font_family = family
|
||||
return family
|
||||
|
||||
platform_families = {
|
||||
"win32": ("Microsoft YaHei UI", "Microsoft YaHei"),
|
||||
@@ -1259,12 +1461,27 @@ class _BusinessDialogStyleFilter(QObject):
|
||||
or dialog.testAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
)
|
||||
|
||||
#: The filter is installed on the QApplication, so it is handed every event
|
||||
#: in the process - roughly 5,000 per list refresh. Only two event types
|
||||
#: matter, and bailing on the type before touching ``isinstance`` keeps the
|
||||
#: hot path to a single comparison.
|
||||
_WATCHED = frozenset({QEvent.Type.Polish, QEvent.Type.Show})
|
||||
|
||||
#: Reading surfaces that get eased wheel scrolling when they are polished.
|
||||
#: Deliberately not every ``QAbstractScrollArea``: animating the scrollbar of
|
||||
#: an item view that hosts a widget per row means repainting those widgets
|
||||
#: for the length of the animation, which would trade a jerky scroll for a
|
||||
#: slow one. The list pages opt their own tables in individually.
|
||||
_SMOOTH_SCROLL = (QScrollArea, QTextEdit, QTextBrowser, QPlainTextEdit)
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # noqa: N802
|
||||
event_type = event.type()
|
||||
if isinstance(watched, QDialogButtonBox) and event_type in {
|
||||
QEvent.Type.Polish,
|
||||
QEvent.Type.Show,
|
||||
}:
|
||||
if event_type not in self._WATCHED:
|
||||
return False
|
||||
if event_type is QEvent.Type.Polish and isinstance(watched, self._SMOOTH_SCROLL):
|
||||
motion.install_smooth_scroll(watched)
|
||||
return False
|
||||
if isinstance(watched, QDialogButtonBox):
|
||||
_polish_dialog_buttons(watched)
|
||||
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Polish:
|
||||
# Window flags can only be changed before the dialog is on screen,
|
||||
@@ -1305,11 +1522,17 @@ def apply_theme(app: QApplication) -> None:
|
||||
|
||||
app.setStyle("Fusion")
|
||||
cjk_family = _register_preferred_cjk_fonts()
|
||||
# Pin the Windows CJK face explicitly. A comma-separated QSS fallback
|
||||
# list can resolve to Qt's generic sans face in offscreen/native title-bar
|
||||
# captures, which changes glyph width and can even yield tofu boxes.
|
||||
application_font = QFont(app.font())
|
||||
application_font.setFamily(cjk_family)
|
||||
# Resolve once for both styled widgets and custom-painted table cells.
|
||||
application_font = QFont(cjk_family)
|
||||
application_font.setPixelSize(int(TYPE["fs_body"].removesuffix("px")))
|
||||
application_font.setWeight(QFont.Weight.Normal)
|
||||
application_font.setStyleStrategy(
|
||||
QFont.StyleStrategy.PreferAntialias
|
||||
| QFont.StyleStrategy.PreferOutline
|
||||
)
|
||||
# Keep the platform's pixel fitting and subpixel rendering available.
|
||||
# Forcing grayscale plus vertical-only hinting softens small Windows text.
|
||||
application_font.setHintingPreference(QFont.HintingPreference.PreferDefaultHinting)
|
||||
app.setFont(application_font)
|
||||
palette = QPalette()
|
||||
active = {
|
||||
@@ -1324,7 +1547,7 @@ def apply_theme(app: QApplication) -> None:
|
||||
QPalette.ColorRole.ButtonText: COLORS["text"],
|
||||
QPalette.ColorRole.Base: COLORS["surface"],
|
||||
QPalette.ColorRole.Window: COLORS["canvas"],
|
||||
QPalette.ColorRole.Shadow: "#B7C0D2",
|
||||
QPalette.ColorRole.Shadow: "#C2C2C2",
|
||||
QPalette.ColorRole.Highlight: COLORS["indigo"],
|
||||
QPalette.ColorRole.HighlightedText: "#FFFFFF",
|
||||
QPalette.ColorRole.Link: COLORS["info"],
|
||||
@@ -1347,7 +1570,7 @@ def apply_theme(app: QApplication) -> None:
|
||||
QPalette.ColorRole.ButtonText: COLORS["disabled_text"],
|
||||
QPalette.ColorRole.Base: COLORS["disabled_surface"],
|
||||
QPalette.ColorRole.Window: COLORS["canvas"],
|
||||
QPalette.ColorRole.Shadow: "#C8CFDC",
|
||||
QPalette.ColorRole.Shadow: "#D2D2D2",
|
||||
QPalette.ColorRole.Highlight: COLORS["line"],
|
||||
QPalette.ColorRole.HighlightedText: COLORS["disabled_text"],
|
||||
QPalette.ColorRole.Link: COLORS["disabled_text"],
|
||||
@@ -1362,7 +1585,7 @@ def apply_theme(app: QApplication) -> None:
|
||||
_apply_group(palette, QPalette.ColorGroup.Inactive, active)
|
||||
_apply_group(palette, QPalette.ColorGroup.Disabled, disabled)
|
||||
app.setPalette(palette)
|
||||
app.setStyleSheet(GLOBAL_QSS)
|
||||
app.setStyleSheet(GLOBAL_QSS + _indicator_qss())
|
||||
_install_business_dialog_styling(app)
|
||||
|
||||
|
||||
@@ -1373,6 +1596,7 @@ __all__ = [
|
||||
"GLOBAL_QSS",
|
||||
"METRICS",
|
||||
"TYPE",
|
||||
"WEIGHTS",
|
||||
"apply_theme",
|
||||
"crisp_pixmap",
|
||||
"mark_business_dialog",
|
||||
|
||||
@@ -10,7 +10,15 @@ from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QObject, QRunnable, Qt, QThreadPool, QTimer, Signal, Slot
|
||||
from PySide6.QtCore import (
|
||||
QObject,
|
||||
QRunnable,
|
||||
Qt,
|
||||
QThreadPool,
|
||||
QTimer,
|
||||
Signal,
|
||||
Slot,
|
||||
)
|
||||
from PySide6.QtGui import QColor, QPainter, QPaintEvent, QResizeEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
@@ -35,6 +43,8 @@ from doctor_workstation.core.errors import (
|
||||
AuthenticationExpiredError,
|
||||
)
|
||||
|
||||
from . import icons, motion
|
||||
|
||||
AuthenticationExpiredHandler = Callable[[AuthenticationExpiredError], bool]
|
||||
_AUTHENTICATION_EXPIRED_HANDLER: AuthenticationExpiredHandler | None = None
|
||||
|
||||
@@ -470,6 +480,8 @@ class PageHeader(QWidget):
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("PageHeader")
|
||||
self._compact = False
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(8)
|
||||
@@ -514,7 +526,31 @@ class PageHeader(QWidget):
|
||||
|
||||
def set_subtitle(self, text: str) -> None:
|
||||
self.subtitle_label.setText(text)
|
||||
self.subtitle_label.setVisible(bool(text))
|
||||
self.subtitle_label.setVisible(bool(text) and not self._compact)
|
||||
self.title_label.setToolTip(text if self._compact else "")
|
||||
|
||||
def set_compact(self, compact: bool = True) -> None:
|
||||
"""Use a single title/action row on list pages with foldable search."""
|
||||
self._compact = compact
|
||||
layout = self.layout()
|
||||
breadcrumb = layout.itemAt(0).layout()
|
||||
for index in range(breadcrumb.count()):
|
||||
widget = breadcrumb.itemAt(index).widget()
|
||||
if widget is not None:
|
||||
widget.setVisible(not compact)
|
||||
self.subtitle_label.setVisible(bool(self.subtitle_label.text()) and not compact)
|
||||
self.title_label.setToolTip(self.subtitle_label.text() if compact else "")
|
||||
layout.setSpacing(0 if compact else 8)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
|
||||
heading = layout.itemAt(1).layout()
|
||||
heading.setAlignment(Qt.AlignmentFlag.AlignVCenter)
|
||||
heading.itemAt(0).layout().setSpacing(0 if compact else 3)
|
||||
self.actions.setSpacing(8)
|
||||
if compact:
|
||||
self.setFixedHeight(44)
|
||||
else:
|
||||
self.setMinimumHeight(0)
|
||||
self.setMaximumHeight(16777215)
|
||||
|
||||
|
||||
class MetricCard(QFrame):
|
||||
@@ -636,7 +672,9 @@ class MessageBanner(QFrame):
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(12, 9, 12, 9)
|
||||
layout.setSpacing(9)
|
||||
self.icon = QLabel("i", self)
|
||||
# The banner used to letter its own icons - a lowercase "i", a "!", and
|
||||
# a U+2713 whose shape depended on whichever font happened to cover it.
|
||||
self.icon = QLabel(self)
|
||||
self.icon.setObjectName("MessageBannerIcon")
|
||||
self.icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.icon.setFixedSize(20, 20)
|
||||
@@ -647,18 +685,33 @@ class MessageBanner(QFrame):
|
||||
layout.addWidget(self.label, 1)
|
||||
self.setVisible(bool(text))
|
||||
|
||||
#: Banner kind -> shared glyph and colour role.
|
||||
_ICONS = {
|
||||
"info": ("info", "info"),
|
||||
"success": ("check_circle", "success"),
|
||||
"warning": ("alert", "warning"),
|
||||
"danger": ("alert", "danger"),
|
||||
}
|
||||
|
||||
def show_message(self, text: str, kind: str = "info") -> None:
|
||||
glyphs = {"info": "i", "success": "✓", "warning": "!", "danger": "!"}
|
||||
glyph, role = self._ICONS.get(kind, self._ICONS["info"])
|
||||
self.label.setText(text)
|
||||
self.icon.setText(glyphs.get(kind, "i"))
|
||||
self.icon.setPixmap(icons.pixmap(glyph, role, 16))
|
||||
self.setProperty("kind", kind)
|
||||
self.style().unpolish(self)
|
||||
self.style().polish(self)
|
||||
self.setVisible(bool(text))
|
||||
if not text:
|
||||
self.setVisible(False)
|
||||
return
|
||||
if self.isVisible():
|
||||
return
|
||||
motion.fade_in(self, duration=motion.FAST)
|
||||
|
||||
def clear(self) -> None:
|
||||
self.setVisible(False)
|
||||
self.label.clear()
|
||||
if self.isVisible():
|
||||
motion.fade_out(self, duration=motion.FAST, on_finished=self.label.clear)
|
||||
else:
|
||||
self.label.clear()
|
||||
|
||||
|
||||
class Toast(QLabel):
|
||||
@@ -670,7 +723,10 @@ class Toast(QLabel):
|
||||
self.hide()
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(self.hide)
|
||||
self._timer.timeout.connect(self._dismiss)
|
||||
|
||||
def _dismiss(self) -> None:
|
||||
motion.fade_out(self, duration=motion.BASE)
|
||||
|
||||
def show_message(self, text: str, kind: str = "info", duration: int = 2800) -> None:
|
||||
self.setText(text)
|
||||
@@ -682,7 +738,7 @@ class Toast(QLabel):
|
||||
if parent is not None:
|
||||
self.move(max(16, parent.width() - self.width() - 24), 20)
|
||||
self.raise_()
|
||||
self.show()
|
||||
motion.fade_in(self, duration=motion.FAST)
|
||||
self._timer.start(duration)
|
||||
|
||||
|
||||
@@ -730,7 +786,7 @@ class _BusyTrack(QWidget):
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
rect = self.rect()
|
||||
painter.setBrush(QColor("#D8DEEA"))
|
||||
painter.setBrush(QColor("#E4E4E5"))
|
||||
painter.drawRoundedRect(rect, 3, 3)
|
||||
chunk_width = max(36, int(rect.width() * 0.32))
|
||||
span = rect.width() + chunk_width
|
||||
@@ -766,6 +822,16 @@ class BusyOverlay(QFrame):
|
||||
self.raise_()
|
||||
super().showEvent(event)
|
||||
|
||||
def reveal(self) -> None:
|
||||
"""Fade the guard in, so a fast response never flashes a grey slab."""
|
||||
|
||||
if not self.isVisible():
|
||||
motion.fade_in(self, duration=motion.FAST)
|
||||
|
||||
def dismiss(self) -> None:
|
||||
if self.isVisible():
|
||||
motion.fade_out(self, duration=motion.FAST)
|
||||
|
||||
|
||||
class OverlayHost(QWidget):
|
||||
"""Widget base that automatically sizes a BusyOverlay child."""
|
||||
@@ -795,11 +861,19 @@ class SortableTable(QTableWidget):
|
||||
self.setColumnCount(len(self.columns))
|
||||
self.setHorizontalHeaderLabels([column.title for column in self.columns])
|
||||
self.setAlternatingRowColors(True)
|
||||
# Row separation already comes from the ``::item`` bottom border, so the
|
||||
# grid only added a column rule that cut across every row - and it kept
|
||||
# drawing past the last populated column, leaving a stray vertical line
|
||||
# hanging in the empty part of the table.
|
||||
self.setShowGrid(False)
|
||||
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
# Per-pixel mode only smooths dragging; the wheel still jumped three rows
|
||||
# at a time, which is how most of a list actually gets read.
|
||||
motion.install_smooth_scroll(self)
|
||||
self.setMinimumHeight(0)
|
||||
self.setSortingEnabled(True)
|
||||
self.verticalHeader().setVisible(False)
|
||||
|
||||
@@ -1634,7 +1634,11 @@ def test_report_bubbles_report_the_height_they_actually_paint(
|
||||
host, bubble = _fitted_bubble(reply)
|
||||
|
||||
assert bubble.height() > 0
|
||||
assert abs(bubble.sizeHint().height() - bubble.height()) <= 2
|
||||
# Wrapping labels can legitimately have a different preferred height at
|
||||
# their preferred width. Compare against the actual reading-column width.
|
||||
fitted_height = bubble.heightForWidth(bubble.width())
|
||||
expected_height = fitted_height if fitted_height >= 0 else bubble.sizeHint().height()
|
||||
assert abs(expected_height - bubble.height()) <= 2
|
||||
host.close()
|
||||
host.deleteLater()
|
||||
|
||||
@@ -1646,14 +1650,15 @@ def test_risk_block_uses_the_red_alert_palette() -> None:
|
||||
assert "#FEF3F2" in risk_card
|
||||
assert "#F1B35C" not in risk_card # 旧的橙色描边
|
||||
marker = qss.split("QLabel#AiConsultRiskMarker {", 1)[1].split("}", 1)[0]
|
||||
assert "#C0392B" in marker
|
||||
assert "#BE4B58" in marker
|
||||
|
||||
|
||||
def test_clinical_bodies_are_no_longer_rendered_at_eleven_pixels() -> None:
|
||||
qss = ai_consult_module.AI_CONSULT_QSS
|
||||
|
||||
body = qss.split("QLabel#AiConsultRiskBody {\n color: #46557A;", 1)
|
||||
assert len(body) == 2 or "font-size: 13px" in qss
|
||||
body = qss.rsplit("QLabel#AiConsultRiskBody {", 1)[1].split("}", 1)[0]
|
||||
assert "color: #1a1c1f" in body.lower()
|
||||
assert "font-size: 14px" in body
|
||||
block = qss.split("QLabel#AiConsultClinicalBody,", 1)[1].split("}", 1)[0]
|
||||
assert "font-size: 13px" in block
|
||||
assert "font-size: 14px" in block
|
||||
assert "font-size: 11px" not in block
|
||||
|
||||
@@ -559,7 +559,7 @@ def test_keyboard_focus_has_a_visible_state(
|
||||
assert focus_target.hasFocus()
|
||||
assert application.focusWidget() is focus_target
|
||||
assert 'QPushButton[appointmentDate="true"]:focus' in APPOINTMENT_DRAWER_QSS
|
||||
assert "border-color: #8D9BFF;" in APPOINTMENT_DRAWER_QSS
|
||||
assert "border-color: #8B9AD9;" in APPOINTMENT_DRAWER_QSS
|
||||
|
||||
drawer.close()
|
||||
host.close()
|
||||
|
||||
@@ -10,6 +10,8 @@ from typing import Any
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QElapsedTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
@@ -398,12 +400,19 @@ def test_appointments_page_default_query_is_today_pending(
|
||||
page.refresh()
|
||||
application.processEvents()
|
||||
|
||||
completed = QElapsedTimer()
|
||||
completed.start()
|
||||
while page.table.rowCount() == 0 and completed.elapsed() < 2_000:
|
||||
QTest.qWait(10)
|
||||
|
||||
filters = page._query_filters()
|
||||
assert filters["status"] == 1
|
||||
assert filters["include_status_counts"] == 1
|
||||
assert filters["start_date"] == filters["end_date"]
|
||||
assert "diag_scope_relax" not in filters
|
||||
assert page.table.rowCount() >= 1
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_demo_appointment_status_counts_respect_date_scope() -> None:
|
||||
@@ -464,8 +473,7 @@ def test_appointment_multiline_cells_receive_enough_row_height(
|
||||
assert appointment_text.count("\n") == 2
|
||||
assert "2026-08-11 14:30" in appointment_text
|
||||
required = 3 * max(16, page.table.fontMetrics().lineSpacing()) + 10
|
||||
assert 60 <= page.table.rowHeight(0) <= 66
|
||||
assert page.table.rowHeight(0) >= min(required, 66)
|
||||
assert page.table.rowHeight(0) >= required
|
||||
assert page.table.item(0, 4).toolTip() == appointment_text
|
||||
page.close()
|
||||
|
||||
@@ -854,9 +862,8 @@ def test_appointments_density_fits_four_rows_in_1366_shell_viewport(
|
||||
permissions=PermissionSet(["*"]),
|
||||
current_user={"id": 1001, "role_id": 1},
|
||||
)
|
||||
# 1366x768 shell minus its 179 px appointment rail, 26 px outer gutter,
|
||||
# and 62 px top bar leaves a 1161x680 page viewport.
|
||||
page.resize(1161, 680)
|
||||
# Approved shared chrome: 208 px rail, 76 px topbar, no outer gutter.
|
||||
page.resize(1158, 692)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
rows = [
|
||||
@@ -886,10 +893,12 @@ def test_appointments_density_fits_four_rows_in_1366_shell_viewport(
|
||||
application.processEvents()
|
||||
|
||||
heights = [page.table.rowHeight(index) for index in range(page.table.rowCount())]
|
||||
# 与其余列表页一致的“面包屑 + 标题 + 副标题”页头。
|
||||
assert page.header.height() == 62
|
||||
assert page.filter_panel.height() <= 84
|
||||
assert all(60 <= height <= 66 for height in heights)
|
||||
# Compact title and folded filters leave more room for the patient queue.
|
||||
assert page.header.height() >= page.header.minimumSizeHint().height()
|
||||
assert page.header.height() <= 44
|
||||
assert page.filter_panel.isHidden()
|
||||
assert all(60 <= height <= 84 for height in heights)
|
||||
assert all(page.table.cellWidget(row, 4).height() >= page.table.cellWidget(row, 4).minimumSizeHint().height() for row in range(page.table.rowCount()))
|
||||
assert page.table.viewport().height() // max(heights) >= 4
|
||||
assert page.pager.isVisibleTo(page)
|
||||
assert page.content_layout.count() == 1
|
||||
|
||||
@@ -365,9 +365,11 @@ def test_refresh_generation_ignores_late_results(
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(consultations_module, "run_async", queue_async)
|
||||
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
|
||||
page.refresh(silent=True)
|
||||
page.refresh(silent=True)
|
||||
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
|
||||
page.refresh(silent=True)
|
||||
# Identical in-flight requests are deduplicated; a new query supersedes one.
|
||||
page.keyword_edit.setText("新患者")
|
||||
page.refresh(silent=True)
|
||||
|
||||
callbacks[1]["on_success"]({"lists": [_row(id=902, diagnosis_id=902)], "count": 1})
|
||||
callbacks[0]["on_success"]({"lists": [_row(id=901, diagnosis_id=901)], "count": 1})
|
||||
|
||||
@@ -986,14 +986,14 @@ def test_choice_chips_keep_visible_checked_style_when_readonly(
|
||||
application.processEvents()
|
||||
# Sample the pad (not glyph center) so white text does not hide the fill.
|
||||
enabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
|
||||
assert enabled_color.name().lower() == "#f0f2ff"
|
||||
assert enabled_color.name().lower() == "#eef1fa"
|
||||
diet.setReadOnly(True)
|
||||
application.processEvents()
|
||||
disabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
|
||||
assert selected.isChecked()
|
||||
assert selected.isEnabled()
|
||||
assert diet.isReadOnly()
|
||||
assert disabled_color.name().lower() == "#f0f2ff"
|
||||
assert disabled_color.name().lower() == "#eef1fa"
|
||||
dialog.close()
|
||||
application.processEvents()
|
||||
|
||||
@@ -1013,7 +1013,7 @@ def test_view_only_drawer_shows_selected_choice_chips(
|
||||
selected = next(button for button in diet._buttons if button.isChecked())
|
||||
application.processEvents()
|
||||
color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
|
||||
assert color.name().lower() == "#f0f2ff"
|
||||
assert color.name().lower() == "#eef1fa"
|
||||
assert diet.isReadOnly()
|
||||
assert not dialog.save_button.isVisibleTo(dialog)
|
||||
dialog.close()
|
||||
|
||||
@@ -10,11 +10,14 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
import pytest
|
||||
from PySide6.QtCore import QAbstractTableModel, QPoint, QRect, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QImage, QPainter
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QToolButton,
|
||||
QWidget,
|
||||
)
|
||||
@@ -161,27 +164,44 @@ def _page() -> ConsultationsPage:
|
||||
return ConsultationsPage(_CancellationRepository(), permissions=PermissionSet(["*"]))
|
||||
|
||||
|
||||
def _caret_matches(button: object, direction: str) -> bool:
|
||||
"""The disclosure caret is now a shared glyph, not Fusion's arrow type.
|
||||
|
||||
``setArrowType`` drew a solid triangle - the one filled mark in an otherwise
|
||||
all-stroke icon set - so the state is carried by the icon instead, and the
|
||||
direction has to be checked by comparing what was actually painted.
|
||||
"""
|
||||
|
||||
from doctor_workstation.ui import icons
|
||||
|
||||
painted = button.icon().pixmap(14, 14).toImage()
|
||||
expected = icons.pixmap(direction, "muted", 14).toImage()
|
||||
return painted == expected
|
||||
|
||||
|
||||
def test_visual_hierarchy_and_filter_contract(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
page = _page()
|
||||
content_layout = page.page_scroll.widget().layout()
|
||||
margins = content_layout.contentsMargins()
|
||||
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (18, 10, 18, 10)
|
||||
assert content_layout.spacing() == 8
|
||||
# The approved blue shell has no outer gutter; the page owns this spacing.
|
||||
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (27, 24, 26, 8)
|
||||
assert content_layout.spacing() == 10
|
||||
status_card = page.findChild(QFrame, "DiagnosisStatusCard")
|
||||
assert status_card is not None
|
||||
assert page.page_header.height() == 62
|
||||
assert status_card.height() == 50
|
||||
assert page.page_header.maximumHeight() >= page.page_header.minimumSizeHint().height()
|
||||
assert status_card.height() == 54
|
||||
assert page.findChild(QFrame, "DiagnosisFilterCard") is not None
|
||||
assert page.findChild(QFrame, "DiagnosisListCard") is not None
|
||||
assert page.filters_card.height() == 90
|
||||
assert page.keyword_edit.maximumWidth() == 380
|
||||
assert page.filters_card.isHidden()
|
||||
assert page.page_header.height() <= 44
|
||||
assert page.keyword_edit.maximumWidth() == 340
|
||||
assert list(page.status_buttons) == ["1", "", "4", "2", "3"]
|
||||
assert page.status_buttons["1"].isChecked()
|
||||
assert not page.advanced_filters.isVisible()
|
||||
assert page.more_filter_button.text() == "更多筛选"
|
||||
assert page.more_filter_button.arrowType() == Qt.ArrowType.DownArrow
|
||||
assert _caret_matches(page.more_filter_button, "down")
|
||||
assert [page._date_button_labels[key] for key in page.date_buttons] == [
|
||||
"昨天挂号",
|
||||
"前天挂号",
|
||||
@@ -228,7 +248,7 @@ def test_pending_assign_and_secondary_chip_semantics(
|
||||
page._toggle_advanced_filters(True)
|
||||
assert not page.advanced_filters.isHidden()
|
||||
assert page.more_filter_button.text() == "收起"
|
||||
assert page.more_filter_button.arrowType() == Qt.ArrowType.UpArrow
|
||||
assert _caret_matches(page.more_filter_button, "up")
|
||||
assert page.unserved_sort_combo.isHidden()
|
||||
date_ranges = page.advanced_filters.findChildren(QFrame, "DiagnosisDateRange")
|
||||
assert len(date_ranges) == 2
|
||||
@@ -237,15 +257,81 @@ def test_pending_assign_and_secondary_chip_semantics(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_toolbar_cancel_tracks_single_appointment_without_changing_checked_rows(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
page = _page()
|
||||
single = _row(880)
|
||||
multiple = _row(881, appointments=[
|
||||
{"id": 8801, "status": 1}, {"id": 8802, "status": 3},
|
||||
])
|
||||
page.table_host.set_rows([single, multiple])
|
||||
page.table.selectRow(0)
|
||||
page._selection_changed()
|
||||
assert page.cancel_toolbar_button.isEnabled()
|
||||
assert page.case_toolbar_button.isEnabled()
|
||||
assert not page.call_toolbar_button.isEnabled()
|
||||
page.table.selectRow(1)
|
||||
page._selection_changed()
|
||||
assert not page.cancel_toolbar_button.isEnabled()
|
||||
assert page.table_host.selected_records() == []
|
||||
page.table_host.set_rows([])
|
||||
page._selection_changed()
|
||||
assert not page.case_toolbar_button.isEnabled()
|
||||
assert not page.prescription_toolbar_button.isEnabled()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", [(816, 564), (1328, 884)])
|
||||
def test_filter_rows_and_toolbar_stay_inside_their_panels_when_wrapping(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
size: tuple[int, int],
|
||||
) -> None:
|
||||
page = _page()
|
||||
monkeypatch.setattr(page, "refresh", lambda silent=False: None)
|
||||
page.resize(*size)
|
||||
page.show()
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
for expanded in (False, True):
|
||||
page._toggle_advanced_filters(expanded)
|
||||
if expanded:
|
||||
page._choose_pending_assign()
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
controls = [*page.date_buttons.values(), page.custom_date_edit,
|
||||
page.confirmed_combo, page.department_combo, page.keyword_edit,
|
||||
page.more_filter_button]
|
||||
if expanded:
|
||||
controls.extend([page.pending_assign_month, page.pending_assign_keyword,
|
||||
page.channel_combo, page.latest_assign_end_date])
|
||||
rectangles = [QRect(control.mapTo(page.filters_card, QPoint()), control.size())
|
||||
for control in controls if control.isVisible()]
|
||||
assert all(page.filters_card.rect().contains(rect) for rect in rectangles)
|
||||
for index, rect in enumerate(rectangles):
|
||||
assert all(not rect.intersects(other) for other in rectangles[index + 1:])
|
||||
for button in (page.add_button, page.cancel_toolbar_button,
|
||||
page.complete_toolbar_button, page.refresh_button):
|
||||
if button.isVisible():
|
||||
assert page.list_toolbar.rect().contains(
|
||||
QRect(button.mapTo(page.list_toolbar, QPoint()), button.size())
|
||||
)
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_dedicated_model_fixed_columns_selection_and_sort(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
page = _page()
|
||||
assert isinstance(page.table.model(), QAbstractTableModel)
|
||||
assert isinstance(page.table.model(), DiagnosisTableModel)
|
||||
assert page.table_host.LEFT_WIDTHS == (48, 70, 60, 100, 175, 88, 120, 100, 72, 110)
|
||||
assert page.table_host.FIXED_WIDTHS == (120, 410)
|
||||
assert page.table_host.fixed.width() == 532
|
||||
assert page.table_host.LEFT_WIDTHS == (48, 70, 82, 102, 244, 90, 84, 90, 88, 122)
|
||||
# Reference-aligned video/actions remain frozen in a compact 250px pane.
|
||||
assert page.table_host.FIXED_WIDTHS == (92, 158)
|
||||
assert page.table_host.fixed.width() == 250
|
||||
assert page.table.isColumnHidden(10)
|
||||
assert page.table_host.fixed.isColumnHidden(9)
|
||||
assert not page.table_host.fixed.isColumnHidden(10)
|
||||
@@ -267,6 +353,9 @@ def test_dedicated_model_fixed_columns_selection_and_sort(
|
||||
page.table_host.sort_unserved_requested.connect(requested.append)
|
||||
assert page.table_host.model.headerData(9, Qt.Orientation.Horizontal) == "未服务天数"
|
||||
assert page.table_host.model._sort_direction == ""
|
||||
# This model/action test keeps its fixture rows; a real server sort starts
|
||||
# a new query and intentionally resets the loaded list.
|
||||
monkeypatch.setattr(page, "refresh", lambda silent=False: None)
|
||||
page.table_host.main.horizontalHeader().sectionClicked.emit(9)
|
||||
assert requested == ["desc"]
|
||||
assert page.table_host.model._sort_direction == "desc"
|
||||
@@ -300,7 +389,7 @@ def test_dedicated_model_fixed_columns_selection_and_sort(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_empty_loading_and_full_pager_keep_the_table_shell(
|
||||
def test_empty_loading_and_compact_footer_keep_the_table_shell(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
page = _page()
|
||||
@@ -321,13 +410,13 @@ def test_empty_loading_and_full_pager_keep_the_table_shell(
|
||||
page.loading_overlay.stop()
|
||||
|
||||
page.pager.update_state(3, 97)
|
||||
assert 40 <= page.pager.height() <= 44
|
||||
assert page.pager.height() == 24
|
||||
pager_margins = page.pager.layout().contentsMargins()
|
||||
assert pager_margins.top() >= 4
|
||||
assert pager_margins.bottom() >= 4
|
||||
assert [page.pager.size_combo.itemData(index) for index in range(4)] == [15, 20, 30, 40]
|
||||
assert len([button for button in page.pager._page_buttons if not button.isHidden()]) == 5
|
||||
assert page.pager.jumper.maximum() == 7
|
||||
assert pager_margins.top() == 0
|
||||
assert pager_margins.bottom() == 0
|
||||
assert not page.pager.findChildren(QComboBox)
|
||||
assert not page.pager.findChildren(QSpinBox)
|
||||
assert not page.pager.findChildren(QToolButton)
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
@@ -335,11 +424,11 @@ def test_empty_loading_and_full_pager_keep_the_table_shell(
|
||||
@pytest.mark.parametrize(
|
||||
("record", "stripe", "channel"),
|
||||
[
|
||||
(_row(701, DiagnosisViewRecord=[{"is_confirmed": 0}]), "#9a6813", "warning"),
|
||||
(_row(702, has_appointment=0, appointments=[]), "#2f6edb", "info"),
|
||||
(_row(701, DiagnosisViewRecord=[{"is_confirmed": 0}]), "#a9691d", "warning"),
|
||||
(_row(702, has_appointment=0, appointments=[]), "#4f63d9", "info"),
|
||||
],
|
||||
)
|
||||
def test_semantic_hover_preserves_gradient_and_three_pixel_stripe(
|
||||
def test_semantic_hover_preserves_three_pixel_stripe_on_neutral_background(
|
||||
application: QApplication,
|
||||
record: dict[str, Any],
|
||||
stripe: str,
|
||||
@@ -363,8 +452,9 @@ def test_semantic_hover_preserves_gradient_and_three_pixel_stripe(
|
||||
assert image.pixelColor(0, 20).name() == stripe
|
||||
assert image.pixelColor(1, 20).name() == stripe
|
||||
assert image.pixelColor(2, 20).name() == stripe
|
||||
gradient = image.pixelColor(6, 20)
|
||||
assert gradient.name() != "#f8f8f8", f"{channel} hover collapsed to a neutral row"
|
||||
assert image.pixelColor(3, 20).name() == "#f7f7f7", f"{channel} stripe widened"
|
||||
assert image.pixelColor(6, 20).name() == "#f7f7f7"
|
||||
assert image.pixelColor(40, 20).name() == "#f7f7f7"
|
||||
|
||||
|
||||
def test_page_hides_fixed_shadow_and_preserves_admin_token_contract(
|
||||
@@ -378,9 +468,7 @@ def test_page_hides_fixed_shadow_and_preserves_admin_token_contract(
|
||||
assert shadow.isHidden()
|
||||
assert shadow.width() == 12
|
||||
assert shadow.geometry().right() == page.table_host.fixed.geometry().left() - 1
|
||||
assert 'font-family: "PingFang SC", Arial, "Hiragino Sans GB", "Microsoft YaHei"' in (
|
||||
DIAGNOSIS_INDEX_QSS
|
||||
)
|
||||
assert "font-family:" not in DIAGNOSIS_INDEX_QSS
|
||||
assert "QTableView:focus" in DIAGNOSIS_INDEX_QSS
|
||||
assert "QToolButton[rowLink]:focus" in DIAGNOSIS_INDEX_QSS
|
||||
assert '#DiagnosisIndex QToolButton[diagnosisChip="true"][semantic="warning"]' in (
|
||||
@@ -491,7 +579,7 @@ def test_full_more_menu_requires_each_real_repository_capability(
|
||||
min(danger_image.width(), danger_rect.right() + 1),
|
||||
):
|
||||
color = danger_image.pixelColor(x, y)
|
||||
if color.red() > 190 and color.green() < 150 and color.blue() < 150:
|
||||
if color.red() > color.green() + 60 and color.red() > color.blue() + 60:
|
||||
red_text_pixels += 1
|
||||
assert red_text_pixels > 8, "删除文案必须由 danger 色绘制,不能回退成原生黑色"
|
||||
more.menu().hide()
|
||||
@@ -642,7 +730,7 @@ def test_error_state_is_persistent_until_rows_replace_it(application: QApplicati
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("size", "minimum_visible_rows"),
|
||||
[((1366, 768), 4), ((1710, 920), 7)],
|
||||
[((1366, 768), 3), ((1710, 920), 4)],
|
||||
)
|
||||
def test_two_desktop_sizes_keep_pager_visible_and_scroll_rows_inside_table(
|
||||
application: QApplication,
|
||||
@@ -686,9 +774,10 @@ def test_two_desktop_sizes_keep_pager_visible_and_scroll_rows_inside_table(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_frozen_rows_track_main_pixel_scroll_and_host_height_is_page_size_stable(
|
||||
def test_frozen_rows_track_main_pixel_scroll_and_host_height_is_append_stable(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
immediate_async: None,
|
||||
) -> None:
|
||||
page = _page()
|
||||
rows = [
|
||||
@@ -704,15 +793,26 @@ def test_frozen_rows_track_main_pixel_scroll_and_host_height_is_page_size_stable
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
host_height = page.table_host.height()
|
||||
calls: list[int] = []
|
||||
|
||||
monkeypatch.setattr(page, "refresh", lambda silent=False: None)
|
||||
page._change_page_size(40)
|
||||
page.table_host.set_rows(rows)
|
||||
def list_consultations(**query: Any) -> dict[str, Any]:
|
||||
calls.append(query["page_no"])
|
||||
start = (query["page_no"] - 1) * query["page_size"]
|
||||
return {"lists": rows[start:start + query["page_size"]], "count": len(rows)}
|
||||
|
||||
monkeypatch.setattr(page.repository, "list_consultations", list_consultations, raising=False)
|
||||
page.refresh(silent=True)
|
||||
for _ in range(2):
|
||||
page.table.verticalScrollBar().setValue(page.table.verticalScrollBar().maximum())
|
||||
QTest.qWait(50)
|
||||
application.processEvents()
|
||||
for _ in range(4):
|
||||
application.processEvents()
|
||||
|
||||
main_scroll = page.table.verticalScrollBar()
|
||||
fixed_scroll = page.table_host.fixed.verticalScrollBar()
|
||||
assert calls == [1, 2, 3]
|
||||
assert page.table.rowCount() == 40
|
||||
assert page.table_host.height() == host_height
|
||||
assert main_scroll.maximum() == fixed_scroll.maximum()
|
||||
main_scroll.setValue(main_scroll.maximum() // 2)
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QDate, QPoint
|
||||
from PySide6.QtCore import QDate, QPoint, QRect
|
||||
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QInputDialog, QLabel
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
@@ -175,6 +175,8 @@ def test_patient_refresh_generation_ignores_late_results(
|
||||
monkeypatch.setattr(patients_module, "run_async", queue_async)
|
||||
workspace = PatientListWorkspace(SimpleNamespace(), PermissionSet(["*"]))
|
||||
workspace.refresh()
|
||||
# Identical in-flight queries coalesce; a changed query starts a new generation.
|
||||
workspace.keyword_edit.setText("新结果")
|
||||
workspace.refresh()
|
||||
newer = {
|
||||
"lists": [{"id": 2, "diagnosis_id": 2, "patient_name": "新结果"}],
|
||||
@@ -621,62 +623,72 @@ def test_patient_list_reference_geometry_and_row_actions(
|
||||
repository = DemoDoctorRepository()
|
||||
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
|
||||
page = PatientsPage(repository, permissions=session.permissions, current_user=session.user)
|
||||
# 1366x768 shell minus its 170 px patient rail, 26 px outer gutter,
|
||||
# and 62 px top bar leaves a 1170x680 page viewport.
|
||||
page.resize(1170, 680)
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
# Patient pages use the approved 208 px rail and 76 px top bar.
|
||||
page.resize(1536 - 208, 960 - 76)
|
||||
page.show()
|
||||
application.processEvents()
|
||||
page.patient_workspace.refresh()
|
||||
application.processEvents()
|
||||
|
||||
workspace = page.patient_workspace
|
||||
assert page.header.height() == 62
|
||||
assert workspace.filter_card.height() <= 92
|
||||
assert all(
|
||||
button.minimumHeight() == 44 and button.maximumHeight() == 44
|
||||
for button in workspace.summary_buttons.values()
|
||||
)
|
||||
assert workspace.keyword_edit.objectName() == "PatientKeywordInput"
|
||||
assert workspace.status_host.objectName() == "PatientStatusFilterHost"
|
||||
assert workspace.quick_host.objectName() == "PatientQuickDateHost"
|
||||
assert workspace.date_host.objectName() == "PatientDateRangeHost"
|
||||
assert (
|
||||
workspace.keyword_edit.maximumWidth(),
|
||||
workspace.status_host.maximumWidth(),
|
||||
workspace.quick_host.maximumWidth(),
|
||||
workspace.date_host.maximumWidth(),
|
||||
) == (620, 440, 620, 420)
|
||||
assert workspace.search_button.objectName() == "PatientSearchButton"
|
||||
assert workspace.reset_button.objectName() == "PatientResetButton"
|
||||
assert workspace.custom_date_button.objectName() == "PatientCustomDateButton"
|
||||
for width in (1170, 1290, 1514):
|
||||
page.resize(width, 680)
|
||||
application.processEvents()
|
||||
for widget in (
|
||||
workspace.keyword_edit,
|
||||
workspace.status_host,
|
||||
workspace.search_button,
|
||||
workspace.reset_button,
|
||||
workspace.quick_host,
|
||||
workspace.date_host,
|
||||
workspace.custom_date_button,
|
||||
assert workspace.table.rowCount() == 4
|
||||
assert workspace.table.rowViewportPosition(3) + workspace.table.rowHeight(3) <= workspace.table.viewport().height()
|
||||
assert workspace.table.horizontalScrollBar().maximum() == 0
|
||||
assert workspace.pager.summary_label.text() == "共 4 条 · 已全部加载"
|
||||
assert workspace.pager.height() == 24
|
||||
assert workspace.pager.page_size == 15
|
||||
assert workspace.pager.page == 1
|
||||
assert not workspace.pager.has_more
|
||||
for width, height in ((1536, 960), (1366, 768), (1024, 640)):
|
||||
page.resize(width - 208, height - 76)
|
||||
for _ in range(3):
|
||||
application.processEvents()
|
||||
assert page.header.height() >= page.header.minimumSizeHint().height()
|
||||
for host, widgets in (
|
||||
(workspace.search_toolbar, (workspace.keyword_edit, workspace.search_button, workspace.reset_button)),
|
||||
(workspace.filter_card, (workspace.status_host, workspace.quick_host, workspace.date_host, workspace.custom_date_button)),
|
||||
(workspace.status_host, tuple(workspace.status_buttons.values())),
|
||||
(workspace.quick_host, tuple(workspace.quick_buttons.values())),
|
||||
(workspace.date_host, (workspace.start_date, workspace.end_date)),
|
||||
(workspace.summary_strip, tuple(workspace.summary_buttons.values())),
|
||||
):
|
||||
top_left = widget.mapTo(workspace.filter_card, QPoint(0, 0))
|
||||
assert top_left.x() >= 0
|
||||
assert top_left.x() + widget.width() <= workspace.filter_card.width()
|
||||
assert all(button.maximumWidth() == 420 for button in workspace.summary_buttons.values())
|
||||
for widget in widgets:
|
||||
assert widget.isVisibleTo(page)
|
||||
assert host.rect().contains(QRect(widget.mapTo(host, QPoint()), widget.size())), widget.objectName()
|
||||
for host in (workspace.search_toolbar, workspace.filter_card, workspace.summary_strip, workspace.pager):
|
||||
if workspace.content.isAncestorOf(host):
|
||||
workspace.scroll.ensureWidgetVisible(host, 0, 0)
|
||||
application.processEvents()
|
||||
assert workspace.scroll.viewport().rect().contains(QRect(host.mapTo(workspace.scroll.viewport(), QPoint()), host.size())), host.objectName()
|
||||
else:
|
||||
assert page.rect().contains(QRect(host.mapTo(page, QPoint()), host.size())), host.objectName()
|
||||
assert workspace.pager.isVisibleTo(page)
|
||||
workspace.table.scrollToBottom()
|
||||
application.processEvents()
|
||||
assert workspace.table.rowViewportPosition(3) + workspace.table.rowHeight(3) <= workspace.table.viewport().height()
|
||||
assert page.tabs.minimumHeight() == 0
|
||||
assert workspace.content_stack.minimumHeight() == 0
|
||||
assert workspace.table.minimumHeight() == 0
|
||||
assert workspace.bottom_actions.isHidden()
|
||||
assert workspace.table.viewport().height() // 36 >= 6
|
||||
assert workspace.pager.isVisibleTo(page)
|
||||
assert workspace.table.objectName() == "PatientTable"
|
||||
assert workspace.table.columnCount() == 10
|
||||
assert workspace.table.horizontalHeaderItem(9).text() == "操作"
|
||||
if workspace.table.rowCount():
|
||||
assert workspace.table.rowHeight(0) == 36
|
||||
assert workspace.table.cellWidget(0, 0) is not None
|
||||
assert workspace.table.cellWidget(0, 9) is not None
|
||||
assert workspace.table.horizontalHeader().visualIndex(9) == 9
|
||||
assert all(not workspace.table.isColumnHidden(column) for column in range(10))
|
||||
actions = workspace.table.cellWidget(0, 9)
|
||||
assert workspace.table.rowHeight(0) >= max(40, actions.minimumSizeHint().height() + 1)
|
||||
for button in [*actions.buttons, actions.more_button]:
|
||||
if button is not None:
|
||||
assert actions.rect().contains(button.geometry())
|
||||
assert workspace.table.cellWidget(0, 0) is not None
|
||||
assert workspace.table.cellWidget(0, 9) is not None
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
@@ -1,268 +1,278 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint
|
||||
from PySide6.QtGui import QImage
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QPushButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from doctor_workstation.ui.pages import prescription_library as library_module
|
||||
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
|
||||
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
|
||||
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
|
||||
from doctor_workstation.ui.widgets import BusinessPager
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def run_immediately(
|
||||
function: Any,
|
||||
*args: Any,
|
||||
on_success: Any = None,
|
||||
on_error: Any = None,
|
||||
on_finished: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> object:
|
||||
try:
|
||||
result = function(*args, **kwargs)
|
||||
except Exception as error:
|
||||
if on_error is not None:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(prescriptions_module, "run_async", run_immediately)
|
||||
monkeypatch.setattr(library_module, "run_async", run_immediately)
|
||||
|
||||
|
||||
def _issued_row(index: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": 1000 + index,
|
||||
"sn": f"CF-202608-{1000 + index}",
|
||||
"prescription_type": "汤剂",
|
||||
"is_system_auto": index % 2,
|
||||
"patient_name": ("林晓岚", "周明远", "许安然")[index % 3],
|
||||
"gender": 2 if index % 2 else 1,
|
||||
"age": 29 + index,
|
||||
"audit_status": index % 3,
|
||||
"void_status": 0,
|
||||
"has_prescription_order": index % 2,
|
||||
"creator_id": 7,
|
||||
"doctor_name": "陈医生",
|
||||
"assistant_name": "赵医助",
|
||||
"create_time": f"2026-08-{(index % 9) + 10:02d} 09:30:00",
|
||||
"herbs": [{"name": "黄芪", "dosage": 15}],
|
||||
}
|
||||
|
||||
|
||||
def _library_row(index: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": 2000 + index,
|
||||
"prescription_name": ("益气养阴方", "清热祛湿方", "滋阴调和方")[index % 3],
|
||||
"formula_type": "主方" if index % 3 else "辅方",
|
||||
"herbs": [
|
||||
{"name": "黄芪", "dosage": 15},
|
||||
{"name": "党参", "dosage": 12},
|
||||
],
|
||||
"efficacy": ("益气养阴", "清热祛湿", "滋阴补肾")[index % 3],
|
||||
"is_public": index % 2,
|
||||
"disable_edit": 0,
|
||||
"creator_id": 7,
|
||||
"creator_name": "陈医生",
|
||||
"create_time": f"2026-08-{(index % 9) + 10:02d} 08:20:00",
|
||||
}
|
||||
|
||||
|
||||
class DensityRepository:
|
||||
def __init__(self) -> None:
|
||||
self.issued_rows = [_issued_row(index) for index in range(15)]
|
||||
self.library_rows = [_library_row(index) for index in range(15)]
|
||||
|
||||
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
|
||||
return [{"id": 7, "name": "陈医生"}, {"id": 8, "name": "孙医生"}]
|
||||
|
||||
def list_prescriptions(self, **_filters: Any) -> dict[str, Any]:
|
||||
return {"lists": self.issued_rows, "count": 44}
|
||||
|
||||
def list_prescription_templates(self, **_filters: Any) -> dict[str, Any]:
|
||||
return {"lists": self.library_rows, "count": 41}
|
||||
|
||||
|
||||
def _new_page(kind: str) -> PrescriptionsPage | PrescriptionLibraryPage:
|
||||
repository = DensityRepository()
|
||||
user = SimpleNamespace(id=7, name="陈医生", root=1, role_ids=[0])
|
||||
permissions = {"*"}
|
||||
if kind == "issued":
|
||||
page: PrescriptionsPage | PrescriptionLibraryPage = PrescriptionsPage(
|
||||
repository, permissions, user
|
||||
)
|
||||
else:
|
||||
page = PrescriptionLibraryPage(repository, permissions, user)
|
||||
page.refresh()
|
||||
return page
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(5):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _fully_visible_rows(page: PrescriptionsPage | PrescriptionLibraryPage) -> int:
|
||||
viewport = page.table.viewport()
|
||||
return sum(
|
||||
1
|
||||
for row in range(page.table.rowCount())
|
||||
if (
|
||||
(item := page.table.item(row, 0)) is not None
|
||||
and (rect := page.table.visualItemRect(item)).isValid()
|
||||
and rect.top() >= 0
|
||||
and rect.bottom() < viewport.height()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_business_pager_is_shared_fixed_and_not_a_fake_dropdown(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
pager = BusinessPager(15)
|
||||
pager.update_state(2, 44)
|
||||
pager.show()
|
||||
_settle(application)
|
||||
|
||||
assert prescriptions_module.BusinessPager is BusinessPager
|
||||
assert 40 <= pager.height() <= 44
|
||||
assert pager.minimumHeight() == pager.maximumHeight() == 42
|
||||
assert pager.findChildren(QComboBox) == []
|
||||
assert pager.page_size_label.text() == "15 条/页"
|
||||
margins = pager.layout().contentsMargins()
|
||||
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (16, 4, 16, 4)
|
||||
assert pager.page_label is not None and pager.page_label.text() == "2"
|
||||
pager.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["issued", "library"])
|
||||
@pytest.mark.parametrize(
|
||||
("size", "minimum_visible_rows"),
|
||||
[((1366, 768), 6), ((1710, 920), 9)],
|
||||
)
|
||||
def test_desktop_sizes_keep_rows_and_pager_visible_and_aligned(
|
||||
application: QApplication,
|
||||
kind: str,
|
||||
size: tuple[int, int],
|
||||
minimum_visible_rows: int,
|
||||
) -> None:
|
||||
page = _new_page(kind)
|
||||
page.resize(*size)
|
||||
page.show()
|
||||
_settle(application)
|
||||
|
||||
header = page.findChild(QWidget, "PageHeader")
|
||||
toolbar_name = "PrescriptionToolbar" if kind == "issued" else "PrescriptionLibraryToolbar"
|
||||
toolbar = page.findChild(QFrame, toolbar_name)
|
||||
assert header is not None and 60 <= header.height() <= 64
|
||||
assert toolbar is not None and 44 <= toolbar.height() <= 48
|
||||
assert 40 <= page.pager.height() <= 44
|
||||
assert page.pager.minimumHeight() == page.pager.maximumHeight()
|
||||
assert page.table.minimumHeight() == 0
|
||||
assert page.table.horizontalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
assert page.table.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
assert _fully_visible_rows(page) >= minimum_visible_rows
|
||||
|
||||
pager_position = page.pager.mapTo(page, QPoint())
|
||||
assert pager_position.x() >= 0
|
||||
assert pager_position.x() + page.pager.width() <= page.width()
|
||||
assert pager_position.y() >= 0
|
||||
assert pager_position.y() + page.pager.height() <= page.height()
|
||||
page_size_right = page.pager.page_size_label.mapTo(page, QPoint()).x() + (
|
||||
page.pager.page_size_label.width()
|
||||
)
|
||||
assert page_size_right <= page.width()
|
||||
pager_margins = page.pager.layout().contentsMargins()
|
||||
toolbar_margins = toolbar.layout().contentsMargins()
|
||||
assert pager_margins.left() == toolbar_margins.left() == 16
|
||||
assert pager_margins.right() == toolbar_margins.right() == 16
|
||||
|
||||
if kind == "issued":
|
||||
filters = page.findChild(QFrame, "PrescriptionFilterBar")
|
||||
assert filters is not None and 84 <= filters.height() <= 92
|
||||
actions_host = page.table.cellWidget(0, 2)
|
||||
assert actions_host is not None
|
||||
row_edit = next(
|
||||
button
|
||||
for button in actions_host.findChildren(QPushButton)
|
||||
if button.accessibleName() == "编辑处方"
|
||||
)
|
||||
edit_top_left = row_edit.mapTo(page.table.viewport(), row_edit.rect().topLeft())
|
||||
edit_bottom_right = row_edit.mapTo(
|
||||
page.table.viewport(), row_edit.rect().bottomRight()
|
||||
)
|
||||
assert row_edit.text() == "编辑"
|
||||
assert page.table.viewport().rect().contains(edit_top_left)
|
||||
assert page.table.viewport().rect().contains(edit_bottom_right)
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint
|
||||
from PySide6.QtGui import QImage
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QPushButton,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from doctor_workstation.ui.pages import prescription_library as library_module
|
||||
from doctor_workstation.ui.pages import prescriptions as prescriptions_module
|
||||
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
|
||||
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
|
||||
from doctor_workstation.ui.widgets import BusinessPager
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def run_immediately(
|
||||
function: Any,
|
||||
*args: Any,
|
||||
on_success: Any = None,
|
||||
on_error: Any = None,
|
||||
on_finished: Any = None,
|
||||
**kwargs: Any,
|
||||
) -> object:
|
||||
try:
|
||||
result = function(*args, **kwargs)
|
||||
except Exception as error:
|
||||
if on_error is not None:
|
||||
on_error(error)
|
||||
else:
|
||||
if on_success is not None:
|
||||
on_success(result)
|
||||
finally:
|
||||
if on_finished is not None:
|
||||
on_finished()
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(prescriptions_module, "run_async", run_immediately)
|
||||
monkeypatch.setattr(library_module, "run_async", run_immediately)
|
||||
|
||||
|
||||
def _issued_row(index: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": 1000 + index,
|
||||
"sn": f"CF-202608-{1000 + index}",
|
||||
"prescription_type": "汤剂",
|
||||
"is_system_auto": index % 2,
|
||||
"patient_name": ("林晓岚", "周明远", "许安然")[index % 3],
|
||||
"gender": 2 if index % 2 else 1,
|
||||
"age": 29 + index,
|
||||
"audit_status": index % 3,
|
||||
"void_status": 0,
|
||||
"has_prescription_order": index % 2,
|
||||
"creator_id": 7,
|
||||
"doctor_name": "陈医生",
|
||||
"assistant_name": "赵医助",
|
||||
"create_time": f"2026-08-{(index % 9) + 10:02d} 09:30:00",
|
||||
"herbs": [{"name": "黄芪", "dosage": 15}],
|
||||
}
|
||||
|
||||
|
||||
def _library_row(index: int) -> dict[str, Any]:
|
||||
return {
|
||||
"id": 2000 + index,
|
||||
"prescription_name": ("益气养阴方", "清热祛湿方", "滋阴调和方")[index % 3],
|
||||
"formula_type": "主方" if index % 3 else "辅方",
|
||||
"herbs": [
|
||||
{"name": "黄芪", "dosage": 15},
|
||||
{"name": "党参", "dosage": 12},
|
||||
],
|
||||
"efficacy": ("益气养阴", "清热祛湿", "滋阴补肾")[index % 3],
|
||||
"is_public": index % 2,
|
||||
"disable_edit": 0,
|
||||
"creator_id": 7,
|
||||
"creator_name": "陈医生",
|
||||
"create_time": f"2026-08-{(index % 9) + 10:02d} 08:20:00",
|
||||
}
|
||||
|
||||
|
||||
class DensityRepository:
|
||||
def __init__(self) -> None:
|
||||
self.issued_rows = [_issued_row(index) for index in range(15)]
|
||||
self.library_rows = [_library_row(index) for index in range(15)]
|
||||
|
||||
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
|
||||
return [{"id": 7, "name": "陈医生"}, {"id": 8, "name": "孙医生"}]
|
||||
|
||||
def list_prescriptions(self, **_filters: Any) -> dict[str, Any]:
|
||||
return {"lists": self.issued_rows, "count": 44}
|
||||
|
||||
def list_prescription_templates(self, **_filters: Any) -> dict[str, Any]:
|
||||
return {"lists": self.library_rows, "count": 41}
|
||||
|
||||
|
||||
def _new_page(kind: str) -> PrescriptionsPage | PrescriptionLibraryPage:
|
||||
repository = DensityRepository()
|
||||
user = SimpleNamespace(id=7, name="陈医生", root=1, role_ids=[0])
|
||||
permissions = {"*"}
|
||||
if kind == "issued":
|
||||
page: PrescriptionsPage | PrescriptionLibraryPage = PrescriptionsPage(
|
||||
repository, permissions, user
|
||||
)
|
||||
else:
|
||||
page = PrescriptionLibraryPage(repository, permissions, user)
|
||||
page.refresh()
|
||||
return page
|
||||
|
||||
|
||||
def _settle(application: QApplication) -> None:
|
||||
for _ in range(5):
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _fully_visible_rows(page: PrescriptionsPage | PrescriptionLibraryPage) -> int:
|
||||
viewport = page.table.viewport()
|
||||
return sum(
|
||||
1
|
||||
for row in range(page.table.rowCount())
|
||||
if (
|
||||
(item := page.table.item(row, 0)) is not None
|
||||
and (rect := page.table.visualItemRect(item)).isValid()
|
||||
and rect.top() >= 0
|
||||
and rect.bottom() < viewport.height()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_infinite_footer_is_shared_compact_and_has_no_page_controls(application):
|
||||
from doctor_workstation.ui.infinite_list import InfiniteList
|
||||
footer = InfiniteList(15)
|
||||
footer.show()
|
||||
_settle(application)
|
||||
assert prescriptions_module.InfiniteList is InfiniteList
|
||||
assert footer.height() == 24
|
||||
assert footer.findChildren(QComboBox) == []
|
||||
assert not hasattr(footer, "page_size_label")
|
||||
assert not hasattr(footer, "next")
|
||||
assert footer.layout().contentsMargins().bottom() == 0
|
||||
footer.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["issued", "library"])
|
||||
@pytest.mark.parametrize(
|
||||
("size", "minimum_visible_rows"),
|
||||
[((1366, 768), 6), ((1710, 920), 9)],
|
||||
)
|
||||
def test_desktop_sizes_keep_rows_and_pager_visible_and_aligned(
|
||||
application: QApplication,
|
||||
kind: str,
|
||||
size: tuple[int, int],
|
||||
minimum_visible_rows: int,
|
||||
) -> None:
|
||||
page = _new_page(kind)
|
||||
page.resize(*size)
|
||||
page.show()
|
||||
_settle(application)
|
||||
|
||||
header = page.findChild(QWidget, "PageHeader")
|
||||
toolbar_name = "PrescriptionToolbar" if kind == "issued" else "PrescriptionLibraryToolbar"
|
||||
toolbar = page.findChild(QFrame, toolbar_name)
|
||||
assert header is not None and header.height() >= header.minimumSizeHint().height()
|
||||
assert toolbar is not None
|
||||
if kind == "issued":
|
||||
assert toolbar.height() == 64
|
||||
assert page.pager.height() == 24
|
||||
# Approved two-line rows retain complete 14 px text and warning content.
|
||||
minimum_visible_rows = 3 if size[1] == 768 else 5
|
||||
else:
|
||||
# The approved library design adds the four metric cards and readable
|
||||
# multiline herb/date rows. Keep meaningful row and pager reachability.
|
||||
assert 60 <= toolbar.height() <= 70
|
||||
assert page.pager.height() == 24
|
||||
minimum_visible_rows = 2 if size[1] == 768 else 4
|
||||
assert page.pager.minimumHeight() == page.pager.maximumHeight()
|
||||
assert page.table.minimumHeight() == 0
|
||||
assert page.table.horizontalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
assert page.table.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel
|
||||
assert _fully_visible_rows(page) >= minimum_visible_rows
|
||||
|
||||
pager_position = page.pager.mapTo(page, QPoint())
|
||||
assert pager_position.x() >= 0
|
||||
assert pager_position.x() + page.pager.width() <= page.width()
|
||||
assert pager_position.y() >= 0
|
||||
assert pager_position.y() + page.pager.height() <= page.height()
|
||||
page_size_right = page.pager.summary_label.mapTo(page, QPoint()).x() + (
|
||||
page.pager.summary_label.width()
|
||||
)
|
||||
assert page_size_right <= page.width()
|
||||
pager_margins = page.pager.layout().contentsMargins()
|
||||
toolbar_margins = toolbar.layout().contentsMargins()
|
||||
expected_inset = 16 if kind == "issued" else 18
|
||||
assert pager_margins.left() == 12
|
||||
assert toolbar_margins.left() == expected_inset
|
||||
assert pager_margins.right() == 12
|
||||
assert toolbar_margins.right() == expected_inset
|
||||
|
||||
if kind == "issued":
|
||||
filters = page.findChild(QFrame, "PrescriptionFilterBar")
|
||||
assert filters is not None and filters.height() == 144
|
||||
actions_host = page.table.cellWidget(0, 2)
|
||||
assert actions_host is not None
|
||||
row_edit = next(
|
||||
button
|
||||
for button in actions_host.findChildren(QPushButton)
|
||||
if button.accessibleName() == "编辑处方"
|
||||
)
|
||||
edit_top_left = row_edit.mapTo(page.table.viewport(), row_edit.rect().topLeft())
|
||||
edit_bottom_right = row_edit.mapTo(
|
||||
page.table.viewport(), row_edit.rect().bottomRight()
|
||||
)
|
||||
assert row_edit.text() == "编辑"
|
||||
assert page.table.viewport().rect().contains(edit_top_left)
|
||||
assert page.table.viewport().rect().contains(edit_bottom_right)
|
||||
else:
|
||||
filters = page.findChild(QFrame, "PrescriptionLibraryFilterBar")
|
||||
assert filters is not None
|
||||
assert page.name_filter.minimumWidth() < 500
|
||||
filter_right = filters.contentsRect().right()
|
||||
for control in (
|
||||
page.name_filter,
|
||||
page.formula_filter,
|
||||
page.visibility_filter,
|
||||
page.effect_filter,
|
||||
page.query_button,
|
||||
page.reset_button,
|
||||
):
|
||||
right = control.mapTo(filters, QPoint()).x() + control.width()
|
||||
assert right <= filter_right
|
||||
|
||||
page.close()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def test_density_reference_artifacts_exist() -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
expected = {
|
||||
root / "artifacts" / "prescription_list_density" / "prescriptions_1366x768.png": (
|
||||
1366,
|
||||
768,
|
||||
),
|
||||
root / "artifacts" / "prescription_list_density" / "prescriptions_1710x920.png": (
|
||||
1710,
|
||||
920,
|
||||
),
|
||||
root
|
||||
/ "artifacts"
|
||||
/ "prescription_list_density"
|
||||
/ "prescription_library_1366x768.png": (1366, 768),
|
||||
root
|
||||
/ "artifacts"
|
||||
/ "prescription_list_density"
|
||||
/ "prescription_library_1710x920.png": (1710, 920),
|
||||
}
|
||||
for path, dimensions in expected.items():
|
||||
image = QImage(str(path))
|
||||
assert not image.isNull(), path
|
||||
assert (image.width(), image.height()) == dimensions
|
||||
page.filter_disclosure.set_expanded(True)
|
||||
_settle(application)
|
||||
assert page.name_filter.minimumWidth() < 500
|
||||
filter_right = filters.contentsRect().right()
|
||||
for control in (
|
||||
page.name_filter,
|
||||
page.formula_filter,
|
||||
page.visibility_filter,
|
||||
page.effect_filter,
|
||||
page.query_button,
|
||||
page.reset_button,
|
||||
):
|
||||
right = control.mapTo(filters, QPoint()).x() + control.width()
|
||||
assert right <= filter_right
|
||||
|
||||
page.close()
|
||||
_settle(application)
|
||||
|
||||
|
||||
def test_density_reference_artifacts_exist() -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
expected = {
|
||||
root / "artifacts" / "prescription_list_density" / "prescriptions_1366x768.png": (
|
||||
1366,
|
||||
768,
|
||||
),
|
||||
root / "artifacts" / "prescription_list_density" / "prescriptions_1710x920.png": (
|
||||
1710,
|
||||
920,
|
||||
),
|
||||
root
|
||||
/ "artifacts"
|
||||
/ "prescription_list_density"
|
||||
/ "prescription_library_1366x768.png": (1366, 768),
|
||||
root
|
||||
/ "artifacts"
|
||||
/ "prescription_list_density"
|
||||
/ "prescription_library_1710x920.png": (1710, 920),
|
||||
}
|
||||
for path, dimensions in expected.items():
|
||||
image = QImage(str(path))
|
||||
assert not image.isNull(), path
|
||||
assert (image.width(), image.height()) == dimensions
|
||||
|
||||
@@ -140,7 +140,7 @@ def test_number_column_renders_sn_id_and_visible_warning_with_dynamic_height(
|
||||
assert "已有关联业务订单,当前处方存在重复药材:黄 芪" in duplicate_tip
|
||||
assert blank_height > normal_height
|
||||
assert duplicate_height > normal_height
|
||||
assert page.table.columnWidth(1) >= 250
|
||||
assert page.table.columnWidth(1) == 192
|
||||
|
||||
image = page.table.viewport().grab().toImage().convertToFormat(QImage.Format.Format_RGB32)
|
||||
red_pixels = 0
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -200,7 +200,7 @@ def test_library_page_uses_canonical_permissions_and_full_columns(
|
||||
"disable_edit": 1,
|
||||
"creator_id": 9,
|
||||
"creator_name": "张医生",
|
||||
"create_time": "2026-08-10 12:00:00",
|
||||
"create_time": QDate.currentDate().toString("yyyy-MM-dd") + " 12:00:00",
|
||||
}
|
||||
],
|
||||
"count": 1,
|
||||
@@ -237,8 +237,9 @@ def test_library_page_uses_canonical_permissions_and_full_columns(
|
||||
"创建时间",
|
||||
"操作",
|
||||
]
|
||||
assert page.table.cellWidget(0, 2) is not None
|
||||
assert page.table.cellWidget(0, 6) is not None
|
||||
# 处方类型与公开范围改由 _RowDecorationDelegate 绘制,只有操作列仍是真实控件。
|
||||
assert page.table.item(0, 2).data(prescription_module._ROLE_TAG_KIND) == "accent"
|
||||
assert page.table.item(0, 6).data(prescription_module._ROLE_LEAD_ICON) == "lock"
|
||||
assert page.table.cellWidget(0, 9) is not None
|
||||
assert not page.view_button.isHidden() and page.view_button.isEnabled()
|
||||
assert not page.ai_button.isHidden() and page.ai_button.isEnabled()
|
||||
@@ -305,8 +306,10 @@ def test_issued_page_sends_exact_filter_dto_and_row_guards(
|
||||
assert page.table.columnCount() == 11
|
||||
assert page.table.horizontalHeaderItem(2).text() == "操作"
|
||||
assert page.table.cellWidget(0, 2) is not None
|
||||
assert page.table.cellWidget(0, 3) is not None
|
||||
assert page.table.cellWidget(0, 6) is not None
|
||||
# 处方类型与审核状态由 _RowDecorationDelegate 绘制标签,不再为每行每列
|
||||
# 各挂一个 QWidget;这里改为断言驱动绘制的角色数据仍然写入。
|
||||
assert page.table.item(0, 3).data(prescription_module._ROLE_TAG_KIND) == "accent"
|
||||
assert page.table.item(0, 6).data(prescription_module._ROLE_TAG_KIND)
|
||||
row_edit = next(
|
||||
button
|
||||
for button in page.table.cellWidget(0, 2).findChildren(QPushButton)
|
||||
|
||||
@@ -59,7 +59,7 @@ def test_completion_hover_press_and_leave_have_feedback_without_layout_shift(con
|
||||
geometry, neighbor_geometry = button.geometry(), neighbor.geometry()
|
||||
clicked: list[bool] = []
|
||||
button.clicked.connect(lambda: clicked.append(True))
|
||||
assert surface_color(button) == "#fff7f8"
|
||||
assert surface_color(button) == "#ffffff"
|
||||
assert button.cursor().shape() == Qt.CursorShape.PointingHandCursor
|
||||
|
||||
QTest.mouseMove(button, button.rect().center())
|
||||
@@ -81,7 +81,7 @@ def test_completion_hover_press_and_leave_have_feedback_without_layout_shift(con
|
||||
button.clearFocus()
|
||||
app.processEvents()
|
||||
assert clicked == []
|
||||
assert surface_color(button) == "#fff7f8"
|
||||
assert surface_color(button) == "#ffffff"
|
||||
assert button.geometry() == geometry
|
||||
assert neighbor.geometry() == neighbor_geometry
|
||||
|
||||
@@ -93,14 +93,14 @@ def test_completion_disabled_hover_does_not_look_or_act_enabled(controls):
|
||||
button.setEnabled(False)
|
||||
QTest.mouseMove(button, button.rect().center())
|
||||
app.processEvents()
|
||||
assert surface_color(button) == "#f7f8fb"
|
||||
assert surface_color(button) == "#f7fafe"
|
||||
assert button.cursor().shape() == Qt.CursorShape.ArrowCursor
|
||||
QTest.mouseClick(button, Qt.MouseButton.LeftButton)
|
||||
assert clicked == []
|
||||
button.setEnabled(True)
|
||||
app.processEvents()
|
||||
assert button.cursor().shape() == Qt.CursorShape.PointingHandCursor
|
||||
assert surface_color(button) != "#f7f8fb"
|
||||
assert surface_color(button) != "#f7f7f7"
|
||||
|
||||
|
||||
def test_completion_keyboard_focus_is_visible_without_resizing(controls):
|
||||
@@ -112,11 +112,11 @@ def test_completion_keyboard_focus_is_visible_without_resizing(controls):
|
||||
button.setFocus(Qt.FocusReason.TabFocusReason)
|
||||
app.processEvents()
|
||||
assert button.hasFocus()
|
||||
assert surface_color(button, border=True) == "#cf4656"
|
||||
assert surface_color(button, border=True) == "#be4b58"
|
||||
assert surface_color(button, border=True) != border_before
|
||||
assert button.geometry() == geometry
|
||||
assert neighbor.geometry() == neighbor_geometry
|
||||
QTest.mouseMove(button, button.rect().center())
|
||||
app.processEvents()
|
||||
assert surface_color(button) == "#fff0f2"
|
||||
assert surface_color(button, border=True) == "#cf4656"
|
||||
assert surface_color(button, border=True) == "#be4b58"
|
||||
|
||||
@@ -51,6 +51,52 @@ def application() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width", [1114, 1320, 1494])
|
||||
def test_visible_history_updates_grow_clinical_card_without_clipping(
|
||||
application: QApplication,
|
||||
queued_async: list[dict[str, Any]],
|
||||
width: int,
|
||||
) -> None:
|
||||
page = ReceptionPage(DemoDoctorRepository(), PermissionSet(["*"]))
|
||||
page.resize(width, 824)
|
||||
page.show()
|
||||
try:
|
||||
application.processEvents()
|
||||
page.detail_stack.setCurrentIndex(1)
|
||||
for _ in range(8):
|
||||
application.processEvents()
|
||||
label = page.case_labels["present"]
|
||||
clinical = page.clinical_info_group
|
||||
initial_height = clinical.height()
|
||||
initial_scroll_maximum = page.detail_scroll.verticalScrollBar().maximum()
|
||||
history = (
|
||||
"患者自述近期口干,睡眠较浅,日常饮食与作息较规律。\n"
|
||||
"近一周已记录空腹血糖,复诊时携带记录与既往检查报告。\n"
|
||||
"问诊记录包含当前不适、变化时间、生活习惯与既往用药,供医生核对。"
|
||||
)
|
||||
label.setText(history)
|
||||
application.processEvents()
|
||||
assert label.text() == history
|
||||
assert label.height() >= label.heightForWidth(label.width())
|
||||
|
||||
label.setText("\n".join([history] * 5))
|
||||
application.processEvents()
|
||||
assert label.height() >= label.heightForWidth(label.width())
|
||||
assert clinical.height() > initial_height
|
||||
assert page.detail_scroll.verticalScrollBar().maximum() > initial_scroll_maximum
|
||||
assert label.maximumHeight() > label.height()
|
||||
|
||||
expanded_height = clinical.height()
|
||||
label.setText("无特殊不适。")
|
||||
application.processEvents()
|
||||
assert label.height() >= label.heightForWidth(label.width())
|
||||
assert clinical.height() < expanded_height
|
||||
finally:
|
||||
page.close()
|
||||
page.deleteLater()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def run_immediately(
|
||||
@@ -622,6 +668,7 @@ def test_queue_date_picker_filters_the_selected_day(
|
||||
page = ReceptionPage(Repository(), PermissionSet([]))
|
||||
page.show()
|
||||
application.processEvents()
|
||||
page.filter_disclosure.button.click()
|
||||
page.queue_date_button.click()
|
||||
calendar = page.queue_date_button.calendarWidget()
|
||||
assert calendar.isVisible()
|
||||
@@ -1036,14 +1083,14 @@ def test_medication_case_prioritizes_clinical_information_and_keeps_plain_summar
|
||||
caption = medication.findChild(QLabel, "ReceptionCaseFieldCaption")
|
||||
value = medication.findChild(QLabel, "ReceptionCaseFieldValue")
|
||||
assert title is not None and title.font().pixelSize() == 18
|
||||
assert caption is not None and caption.font().pixelSize() == 12
|
||||
assert caption is not None and caption.font().pixelSize() == 13
|
||||
assert value is not None and value.font().pixelSize() == 14
|
||||
assert title.font().weight() >= 700
|
||||
assert caption.font().weight() >= 600
|
||||
assert value.font().weight() >= 700
|
||||
assert title.palette().color(QPalette.ColorRole.WindowText).name() == "#17264d"
|
||||
assert caption.palette().color(QPalette.ColorRole.WindowText).name() == "#617092"
|
||||
assert value.palette().color(QPalette.ColorRole.WindowText).name() == "#253a83"
|
||||
assert title.font().weight() == 600
|
||||
assert caption.font().weight() == 400
|
||||
assert value.font().weight() == 600
|
||||
assert title.palette().color(QPalette.ColorRole.WindowText).name() == "#273244"
|
||||
assert caption.palette().color(QPalette.ColorRole.WindowText).name() == "#5d6b80"
|
||||
assert value.palette().color(QPalette.ColorRole.WindowText).name() == "#273244"
|
||||
assert value.wordWrap()
|
||||
assert value.textInteractionFlags() & Qt.TextInteractionFlag.TextSelectableByMouse
|
||||
|
||||
@@ -1135,7 +1182,7 @@ def test_queue_load_more_accumulates_to_total_boundary(
|
||||
assert [call["page_no"] for call in calls] == [1, 2]
|
||||
assert all(call["page_size"] == 15 for call in calls)
|
||||
assert page.queue_list.count() == 22
|
||||
assert page.queue_summary.text() == "已加载 22 / 共 22 位患者"
|
||||
assert page.queue_summary.text() == "共 22 位患者"
|
||||
assert not page._queue_has_more()
|
||||
page.close()
|
||||
application.processEvents()
|
||||
@@ -1466,11 +1513,11 @@ def test_notify_assistant_is_a_visible_header_action_not_a_more_menu_item(
|
||||
buttons = [
|
||||
button
|
||||
for button in (
|
||||
page.complete_button,
|
||||
page.notify_button,
|
||||
page.history_button,
|
||||
page.video_button,
|
||||
page.more_button,
|
||||
page.complete_button,
|
||||
)
|
||||
if button.isVisibleTo(page)
|
||||
]
|
||||
@@ -1688,8 +1735,8 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome
|
||||
assert page.ai_analysis_card.maximumHeight() > 520
|
||||
assert page.ai_analysis_card.findChildren(QScrollArea) == []
|
||||
assert left.height() == right.height()
|
||||
assert 0 <= right.left() - left.right() - 1 <= 2
|
||||
assert abs(left.width() * 5 - right.width() * 4) <= 10
|
||||
assert right.left() - left.right() - 1 == 10
|
||||
assert abs(left.width() / (left.width() + right.width()) - 0.425) < 0.01
|
||||
assert page.ai_summary_label.width() <= page.ai_analysis_card.contentsRect().width()
|
||||
assert page.ai_summary_label.minimumSizeHint().width() <= 48
|
||||
|
||||
|
||||
@@ -400,9 +400,11 @@ def test_refresh_button_visible_and_f5_uses_same_debounce(harness, application)
|
||||
page.poll_timer.stop()
|
||||
application.processEvents()
|
||||
assert page.refresh_button.isVisible()
|
||||
assert page.refresh_button.text() == "刷新"
|
||||
assert page.refresh_button.width() >= 50
|
||||
assert page.refresh_button.geometry().right() < page.queue_date_button.geometry().left()
|
||||
assert not page.refresh_button.icon().isNull()
|
||||
assert page.refresh_button.accessibleName() == "刷新接诊台"
|
||||
assert page.refresh_button.width() >= 34
|
||||
assert page.refresh_button.geometry().right() < page.filter_disclosure.button.geometry().left()
|
||||
assert not page.queue_date_button.isVisible()
|
||||
page.note_edit.setFocus()
|
||||
application.processEvents()
|
||||
QTest.keyClick(page.note_edit, Qt.Key.Key_F5)
|
||||
|
||||
@@ -11,7 +11,7 @@ from PySide6.QtWidgets import QApplication, QDialog, QFrame, QToolButton, QWidge
|
||||
|
||||
from doctor_workstation.ui import shell as shell_module
|
||||
from doctor_workstation.ui.shell import NavigationItem, ShellWindow
|
||||
from doctor_workstation.ui.theme import apply_theme
|
||||
from doctor_workstation.ui.theme import COLORS, apply_theme
|
||||
|
||||
|
||||
def _logical_pixel(image: Any, x: int, y: int):
|
||||
@@ -134,7 +134,8 @@ def shell_window(
|
||||
),
|
||||
("prescriptions", "已开处方", "笺", "tcm.prescription/lists"),
|
||||
("patients", "我的患者", "患", "firstvisit.myPatient/lists"),
|
||||
("consultations", "问诊列表", "询", "tcm.diagnosis/lists"),
|
||||
("consultations", "问诊列表", "询", "tcm.diagnosis/lists"),
|
||||
("legacy_reference", "原版框架参照", "旧", "legacy.reference/lists"),
|
||||
)
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
@@ -160,38 +161,40 @@ def shell_window(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_shell_matches_reference_geometry_at_both_acceptance_sizes(
|
||||
def test_legacy_shell_matches_reference_geometry_at_both_acceptance_sizes(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
assert shell_window.navigate("legacy_reference")
|
||||
for width, height in ((1024, 640), (1440, 900)):
|
||||
shell_window.resize(width, height)
|
||||
application.processEvents()
|
||||
|
||||
assert shell_window.sidebar.width() == 179
|
||||
# 独立legacy参照路由继续验证原有导轨和外圈留白。
|
||||
assert shell_window.sidebar.width() == 190
|
||||
assert shell_window.topbar.height() == 62
|
||||
assert shell_window.tabs_host.height() == 0
|
||||
assert shell_window.workspace.width() == width - 26 - 179
|
||||
assert shell_window.stack.width() == width - 26 - 179
|
||||
assert shell_window.workspace.width() == width - 26 - 190
|
||||
assert shell_window.stack.width() == width - 26 - 190
|
||||
assert shell_window.stack.height() == height - 26 - 62
|
||||
assert shell_window.stack.geometry().right() < shell_window.workspace.width()
|
||||
assert shell_window.stack.geometry().bottom() < shell_window.workspace.height()
|
||||
|
||||
image = shell_window.grab().toImage()
|
||||
assert _logical_pixel(image, 20, 300).name().lower() in {
|
||||
"#f2f5fd",
|
||||
"#f3f6fd",
|
||||
"#f2f6fe",
|
||||
"#f3f6fe",
|
||||
}
|
||||
# 侧边栏不再是画布上的一块面板:它就是画布本身,所以导轨内任意一点
|
||||
# 都必须与外圈留白同色。原先它是 #F4F7FE→#EEF3FD 的斜向渐变,
|
||||
# 沿整条左边缘都对不上画布,形成一道常驻接缝。
|
||||
assert _logical_pixel(image, 20, 300).name().lower() == COLORS["canvas"].lower()
|
||||
assert _logical_pixel(image, 6, 300).name().lower() == COLORS["canvas"].lower()
|
||||
assert _logical_pixel(image, 610, 20).name().lower() == "#ffffff"
|
||||
assert _logical_pixel(image, 220, 90).name().lower() == "#fcfdfe"
|
||||
assert _logical_pixel(image, 220, 90).name().lower() == COLORS["canvas_mid"].lower()
|
||||
|
||||
|
||||
def test_topbar_search_actions_and_navigation_controls_stay_aligned(
|
||||
def test_legacy_topbar_search_actions_and_navigation_controls_stay_aligned(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
assert shell_window.navigate("legacy_reference")
|
||||
for width, height in ((1024, 640), (1366, 768)):
|
||||
shell_window.resize(width, height)
|
||||
application.processEvents()
|
||||
@@ -243,6 +246,7 @@ def test_registered_pages_are_not_top_level_windows(shell_window: ShellWindow) -
|
||||
def test_reference_shell_has_integrated_search_ai_card_and_window_controls(
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
assert shell_window.navigate("legacy_reference")
|
||||
assert shell_window.windowFlags() & Qt.WindowType.FramelessWindowHint
|
||||
assert (
|
||||
shell_window.global_search.placeholderText() == "搜索患者姓名、手机号、病历号"
|
||||
@@ -405,15 +409,17 @@ def test_shell_ai_entry_without_selection_opens_patient_diagnosis_picker(
|
||||
]
|
||||
|
||||
|
||||
def test_shell_ai_entry_on_reception_still_opens_global_patient_picker(
|
||||
@pytest.mark.parametrize("key", ["appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"])
|
||||
def test_shell_ai_menu_on_approved_pages_opens_global_patient_picker(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
key: str,
|
||||
) -> None:
|
||||
reception = shell_window.pages["reception"]
|
||||
assert isinstance(reception, _ShellPageDouble)
|
||||
assert shell_window.navigate("reception")
|
||||
reception.ai_context_available = True
|
||||
page = shell_window.pages[key]
|
||||
assert isinstance(page, _ShellPageDouble)
|
||||
assert shell_window.navigate(key)
|
||||
page.ai_context_available = True
|
||||
opened: list[tuple[tuple[Any, ...], dict[str, Any]]] = []
|
||||
monkeypatch.setattr(
|
||||
shell_module,
|
||||
@@ -421,25 +427,34 @@ def test_shell_ai_entry_on_reception_still_opens_global_patient_picker(
|
||||
lambda *args, **kwargs: opened.append((args, kwargs)) or False,
|
||||
)
|
||||
|
||||
shell_window.ai_top_button.click()
|
||||
assert shell_window.menu_ai_action.isVisible()
|
||||
shell_window.menu_ai_action.trigger()
|
||||
application.processEvents()
|
||||
|
||||
assert reception.ai_open_count == 0
|
||||
assert page.ai_open_count == 0
|
||||
assert len(opened) == 1
|
||||
assert shell_window.stack.currentWidget() is reception
|
||||
assert shell_window.stack.currentWidget() is page
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key", "permission"),
|
||||
[("appointments", "doctor.appointment/lists"), ("consultations", "tcm.diagnosis/lists"),
|
||||
("patients", "firstvisit.myPatient/lists"), ("prescriptions", "tcm.prescription/lists"),
|
||||
("prescription_library", "tcm.prescriptionLibrary/lists")],
|
||||
)
|
||||
def test_shell_hides_global_ai_entries_without_ai_permission(
|
||||
application: QApplication,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
key: str,
|
||||
permission: str,
|
||||
) -> None:
|
||||
navigation = [
|
||||
NavigationItem(
|
||||
"appointments",
|
||||
key,
|
||||
"问诊列表",
|
||||
"号",
|
||||
_ShellPageDouble,
|
||||
("doctor.appointment/lists",),
|
||||
(permission,),
|
||||
)
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
@@ -452,13 +467,19 @@ def test_shell_hides_global_ai_entries_without_ai_permission(
|
||||
window = ShellWindow(
|
||||
object(),
|
||||
{"user": {"name": "无 AI 权限医生"}, "demo_mode": True},
|
||||
permissions={"doctor.appointment/lists"},
|
||||
permissions={permission},
|
||||
)
|
||||
window.show()
|
||||
application.processEvents()
|
||||
|
||||
assert window.assistant_card.isHidden()
|
||||
assert window.ai_top_button.isHidden()
|
||||
assert not window.menu_ai_action.isVisible()
|
||||
assert window.menu_sidebar_action.isVisible()
|
||||
assert window._active_page_key == key
|
||||
assert window.sidebar.width() == 208
|
||||
assert window.topbar.height() == 76
|
||||
assert window.centralWidget().layout().contentsMargins().isNull()
|
||||
|
||||
window.close()
|
||||
application.processEvents()
|
||||
@@ -640,20 +661,135 @@ def test_non_fixed_tabs_close_and_active_close_renavigates(
|
||||
assert shell_window.stack.currentWidget() is shell_window.pages["appointments"]
|
||||
|
||||
|
||||
def test_sidebar_collapse_preserves_active_navigation(
|
||||
def test_topbar_refresh_button_reloads_whichever_page_is_open(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
assert shell_window.navigate("consultations")
|
||||
"""The button existed in the code but was never added to the layout.
|
||||
|
||||
It also matters more than it used to: list loads no longer raise a banner,
|
||||
so this is the only control that acknowledges a manual reload.
|
||||
"""
|
||||
|
||||
button = shell_window.refresh_button
|
||||
assert button.isVisible()
|
||||
assert button.parentWidget() is shell_window.topbar
|
||||
|
||||
for key in ("consultations", "patients"):
|
||||
assert shell_window.navigate(key)
|
||||
application.processEvents()
|
||||
page = shell_window.pages[key]
|
||||
before = page.refresh_count
|
||||
button.click()
|
||||
application.processEvents()
|
||||
assert page.refresh_count == before + 1
|
||||
|
||||
|
||||
def test_topbar_refresh_button_spins_while_the_page_loads(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
"""A spin that cannot stop is worse than no spin at all."""
|
||||
|
||||
button = shell_window.refresh_button
|
||||
resting = button.icon().cacheKey()
|
||||
|
||||
button.click()
|
||||
application.processEvents()
|
||||
assert button._timer.isActive()
|
||||
|
||||
# The page double never reports itself busy, so the spin ends as soon as the
|
||||
# minimum has elapsed rather than running for the full cap.
|
||||
button._elapsed = _ElapsedStub(button._MIN_MS + 1)
|
||||
button._tick()
|
||||
|
||||
assert not button._timer.isActive()
|
||||
assert button.icon().cacheKey() == resting
|
||||
|
||||
|
||||
class _ElapsedStub:
|
||||
def __init__(self, value: int) -> None:
|
||||
self._value = value
|
||||
|
||||
def elapsed(self) -> int:
|
||||
return self._value
|
||||
|
||||
def restart(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def test_legacy_window_ground_carries_the_only_corner(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
"""The bottom-most layer is the one that rounds; nothing nests inside it.
|
||||
|
||||
The rail used to paint its own 16 px corner on top of a square canvas, so
|
||||
each window corner showed a corner inside a corner.
|
||||
"""
|
||||
|
||||
assert shell_window.navigate("legacy_reference")
|
||||
application.processEvents()
|
||||
image = shell_window.grab().toImage()
|
||||
|
||||
# Outside the ground's corner there is nothing at all ...
|
||||
assert _logical_pixel(image, 2, 2).alpha() == 0
|
||||
# ... and well inside it the ground is the flat canvas colour, both in the
|
||||
# outer gutter and inside the rail.
|
||||
assert _logical_pixel(image, 8, 200).name().lower() == COLORS["canvas"].lower()
|
||||
assert _logical_pixel(image, 60, 200).name().lower() == COLORS["canvas"].lower()
|
||||
|
||||
|
||||
def test_approved_pages_share_shell_geometry_and_other_pages_restore(
|
||||
application: QApplication,
|
||||
shell_window: ShellWindow,
|
||||
) -> None:
|
||||
"""The six approved pages share chrome; each remaining page keeps its geometry."""
|
||||
|
||||
geometries = set()
|
||||
for key in [*shell_window.pages, "reception", "consultations", "appointments", "patients", "prescriptions", "prescription_library"]:
|
||||
assert shell_window.navigate(key)
|
||||
application.processEvents()
|
||||
geometry = (
|
||||
shell_window.sidebar.width(),
|
||||
shell_window.workspace.x(),
|
||||
shell_window.workspace.width(),
|
||||
shell_window.stack.width(),
|
||||
)
|
||||
if key in {"appointments", "consultations", "reception", "patients", "prescriptions", "prescription_library"}:
|
||||
assert geometry == (208, 208, shell_window.width() - 208, shell_window.width() - 208)
|
||||
assert shell_window.topbar.height() == 76
|
||||
assert shell_window.workspace.y() == 0
|
||||
else:
|
||||
assert geometry == (190, 203, shell_window.width() - 216, shell_window.width() - 216)
|
||||
assert shell_window.topbar.height() == 62
|
||||
assert shell_window.workspace.y() == 13
|
||||
geometries.add(geometry)
|
||||
|
||||
assert len(geometries) == 1, f"navigation moved the shell: {geometries}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key", "expanded_width"),
|
||||
[("appointments", 208), ("consultations", 208), ("reception", 208), ("patients", 208), ("prescriptions", 208), ("prescription_library", 208), ("legacy_reference", 190)],
|
||||
)
|
||||
def test_sidebar_collapse_preserves_active_navigation(
|
||||
shell_window: ShellWindow,
|
||||
key: str,
|
||||
expanded_width: int,
|
||||
) -> None:
|
||||
assert shell_window.navigate(key)
|
||||
title = shell_window.nav_buttons[key].text()
|
||||
|
||||
shell_window.toggle_sidebar()
|
||||
assert shell_window.sidebar.width() == 68
|
||||
assert shell_window.nav_buttons["consultations"].text() == ""
|
||||
assert shell_window.nav_buttons["consultations"].isChecked()
|
||||
assert shell_window.nav_buttons[key].text() == ""
|
||||
assert shell_window.nav_buttons[key].isChecked()
|
||||
|
||||
shell_window.toggle_sidebar()
|
||||
assert shell_window.sidebar.width() == 195
|
||||
assert shell_window.nav_buttons["consultations"].text().endswith("问诊列表")
|
||||
assert shell_window.nav_buttons["consultations"].isChecked()
|
||||
assert shell_window.sidebar.width() == expanded_width
|
||||
assert shell_window.nav_buttons[key].text() == title
|
||||
assert shell_window.nav_buttons[key].isChecked()
|
||||
|
||||
|
||||
def test_shell_directional_controls_have_no_unicode_arrow_text(
|
||||
|
||||
@@ -4,8 +4,16 @@ from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QSettings
|
||||
from PySide6.QtGui import QPageSize, QRawFont
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox, QMessageBox, QVBoxLayout
|
||||
from PySide6.QtGui import QFont, QPageSize, QRawFont
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from doctor_workstation import app as app_module
|
||||
from doctor_workstation.app import ApplicationController
|
||||
@@ -54,12 +62,25 @@ def test_theme_resolves_real_chinese_glyphs() -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
apply_theme(application)
|
||||
raw_font = QRawFont.fromFont(application.font())
|
||||
assert raw_font.familyName() == "Noto Sans SC"
|
||||
assert not application.font().styleStrategy() & QFont.StyleStrategy.NoSubpixelAntialias
|
||||
assert application.font().hintingPreference() == QFont.HintingPreference.PreferDefaultHinting
|
||||
glyphs = raw_font.glyphIndexesForString("甄养堂医生工作站")
|
||||
|
||||
assert glyphs
|
||||
assert all(glyph > 0 for glyph in glyphs)
|
||||
assert len(set(glyphs)) > 1
|
||||
|
||||
# QSS must not override the resolved platform face on real controls.
|
||||
for widget in (QLabel("甄养堂医生工作站"), QPushButton("确认接诊")):
|
||||
widget.ensurePolished()
|
||||
resolved = QRawFont.fromFont(widget.font())
|
||||
assert resolved.familyName() == raw_font.familyName()
|
||||
assert all(glyph > 0 for glyph in resolved.glyphIndexesForString(widget.text()))
|
||||
assert not widget.font().styleStrategy() & QFont.StyleStrategy.NoSubpixelAntialias
|
||||
assert widget.font().hintingPreference() == QFont.HintingPreference.PreferDefaultHinting
|
||||
widget.close()
|
||||
|
||||
|
||||
def test_theme_marks_dynamic_business_dialogs_and_semantic_buttons() -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
|
||||
Reference in New Issue
Block a user