Files
zyt/app/src/doctor_workstation/ui/login.py
T
2026-08-12 11:03:28 +08:00

704 lines
28 KiB
Python

"""Account login window for the doctor workstation."""
from __future__ import annotations
from contextlib import suppress
from typing import Any
from PySide6.QtCore import QPoint, QSettings, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QPainter, QPen
from PySide6.QtWidgets import (
QCheckBox,
QFrame,
QHBoxLayout,
QLabel,
QLayout,
QLineEdit,
QMainWindow,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QStyle,
QStyleOptionButton,
QToolButton,
QVBoxLayout,
QWidget,
)
from .widgets import BusyOverlay, MessageBanner, friendly_error, invoke, run_async
def _setting_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in {"1", "true", "yes", "on", "y"}
class _VisibleCheckBox(QCheckBox):
"""Draw the check mark that Qt QSS omits from a colored indicator."""
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt API
super().paintEvent(event)
if not self.isChecked():
return
option = QStyleOptionButton()
self.initStyleOption(option)
indicator = self.style().subElementRect(
QStyle.SubElement.SE_CheckBoxIndicator,
option,
self,
)
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
color = QColor("#FFFFFF" if self.isEnabled() else "#98A2B3")
painter.setPen(QPen(color, 2, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
painter.drawLine(
QPoint(indicator.left() + 4, indicator.center().y()),
QPoint(indicator.left() + 7, indicator.bottom() - 4),
)
painter.drawLine(
QPoint(indicator.left() + 7, indicator.bottom() - 4),
QPoint(indicator.right() - 3, indicator.top() + 4),
)
class LoginWindow(QMainWindow):
"""A responsive login surface with optional demo-repository switching.
``login_succeeded`` emits a dictionary containing ``user``, ``session``,
``repository`` and ``demo_mode``. Keeping the selected repository in the
payload lets the composition root construct the shell without guessing.
The remember-password choice is captured before the worker starts. The
repository receives the corresponding account-metadata flag, while this
window stores the password only after authentication succeeds.
"""
login_succeeded = Signal(object)
authenticated = Signal(object)
login_failed = Signal(str)
server_settings_changed = Signal(dict)
config_changed = Signal(object)
demo_mode_changed = Signal(bool)
def __init__(
self,
repository: Any,
config: Any | None = None,
demo_repository: Any | None = None,
settings: QSettings | None = None,
credential_store: Any | None = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.repository = repository
self.config = config
if demo_repository is None:
demo_repository = getattr(config, "demo_repository", None)
self.demo_repository = demo_repository
self.settings = settings or QSettings("ZhenYangTang", "DoctorWorkstation")
self.credential_store = credential_store
self.active_repository = repository
self.authenticated_user: Any = None
self._loading = False
self._restored_account = ""
self._restored_scope = ""
self.setWindowTitle("甄养堂 · 医生工作站")
self.setMinimumSize(860, 590)
self.resize(1120, 720)
canvas = QWidget()
canvas.setObjectName("LoginCanvas")
canvas.setStyleSheet(
"""
QWidget#LoginCanvas { background-color: #F5F7FB; color: #172033; }
QWidget#LoginBrandPanel {
background-color: #FFFFFF;
border: 1px solid #D8DEEA;
border-radius: 24px;
}
QFrame#LoginCard {
background-color: #FFFFFF;
border: 1px solid #D8DEEA;
border-radius: 20px;
}
QFrame#LoginCard QFrame#SubtleCard {
background-color: #F7F8FC;
border: 1px solid #D8DEEA;
border-radius: 12px;
}
QFrame#LoginCard QLabel { color: #172033; }
QFrame#LoginCard QLabel[role="muted"] { color: #667085; }
QFrame#LoginCard QLabel[role="danger"] { color: #C43E55; }
QFrame#LoginCard QCheckBox#AllowSelfSignedCertificate { color: #9A6813; }
QFrame#LoginCard QLineEdit,
QFrame#LoginCard QSpinBox {
color: #172033;
background-color: #FFFFFF;
border: 1px solid #D8DEEA;
selection-background-color: #DCE3FF;
selection-color: #172033;
}
QFrame#LoginCard QLineEdit:hover,
QFrame#LoginCard QSpinBox:hover { border-color: #4F63D9; }
QFrame#LoginCard QLineEdit:focus,
QFrame#LoginCard QSpinBox:focus { border: 2px solid #4F63D9; }
QFrame#LoginCard QCheckBox { color: #667085; }
QFrame#LoginCard QToolButton {
color: #667085;
background-color: #F7F8FC;
border: 1px solid #D8DEEA;
border-radius: 8px;
}
QFrame#LoginCard QToolButton:hover { color: #172033; background-color: #EEF2F8; }
QFrame#LoginCard QPushButton[variant="primary"] {
color: #FFFFFF;
background-color: #4F63D9;
border-color: #4F63D9;
}
QFrame#LoginCard QPushButton[variant="primary"]:hover {
background-color: #4053C7;
border-color: #4053C7;
}
QFrame#LoginCard QPushButton[variant="secondary"] {
color: #3446AF;
background-color: #E9EDFF;
border-color: #C8D1FF;
}
QFrame#LoginCard QPushButton[variant="secondary"]:hover {
background-color: #DCE3FF;
border-color: #4F63D9;
}
QFrame#LoginCard QPushButton[variant="ghost"] { color: #667085; }
QFrame#LoginCard QPushButton[variant="ghost"]:hover,
QFrame#LoginCard QPushButton[variant="ghost"]:checked {
color: #3446AF;
background-color: #E9EDFF;
border-color: #C8D1FF;
}
QScrollArea#LoginAreaScroll,
QScrollArea#LoginAreaScroll > QWidget > QWidget {
border: 0;
background-color: transparent;
}
"""
)
self.setCentralWidget(canvas)
root = QHBoxLayout(canvas)
root.setContentsMargins(26, 26, 26, 26)
root.setSpacing(26)
root.addWidget(self._build_brand_panel(), 5)
root.addWidget(self._build_login_area(), 6)
self._restore_settings()
def _build_brand_panel(self) -> QWidget:
panel = QWidget()
panel.setObjectName("LoginBrandPanel")
panel.setMinimumWidth(310)
panel.setMaximumWidth(470)
layout = QVBoxLayout(panel)
layout.setContentsMargins(38, 38, 38, 38)
layout.setSpacing(18)
brand_row = QHBoxLayout()
mark = QLabel("诊")
mark.setAlignment(Qt.AlignmentFlag.AlignCenter)
mark.setFixedSize(42, 42)
mark.setStyleSheet(
"color:#3446AF; background:#E9EDFF; border:1px solid #C8D1FF; "
"border-radius:12px; font-size:20px; font-weight:700;"
)
brand_row.addWidget(mark)
brand_name = QLabel("甄养堂医疗")
brand_name.setStyleSheet("color:#172033; font-size:16px; font-weight:700;")
brand_row.addWidget(brand_name)
brand_row.addStretch(1)
layout.addLayout(brand_row)
layout.addStretch(2)
eyebrow = QLabel("医生工作站")
eyebrow.setStyleSheet(
"color:#4F63D9; font-size:11px; font-weight:700; letter-spacing:1px;"
)
layout.addWidget(eyebrow)
headline = QLabel("把诊间工作,\n留在一个安静的界面里。")
headline.setProperty("role", "display")
headline.setWordWrap(True)
layout.addWidget(headline)
description = QLabel("接诊、问诊、患者与处方信息统一呈现,帮助医生专注于每一次沟通。")
description.setWordWrap(True)
description.setStyleSheet("color:#667085; font-size:14px; line-height:1.6;")
layout.addWidget(description)
layout.addStretch(3)
privacy = QLabel("本工作站仅供获授权的医疗人员使用\n请勿在公共设备保存账号")
privacy.setWordWrap(True)
privacy.setStyleSheet("color:#667085; font-size:11px;")
layout.addWidget(privacy)
return panel
def _build_login_area(self) -> QWidget:
area = QScrollArea()
area.setObjectName("LoginAreaScroll")
area.setWidgetResizable(True)
area.setFrameShape(QFrame.Shape.NoFrame)
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
content = QWidget()
content.setObjectName("LoginAreaContent")
area.setWidget(content)
self.login_scroll = area
outer = QVBoxLayout(content)
outer.setContentsMargins(20, 10, 20, 10)
outer.addStretch(1)
self.card = QFrame()
self.card.setObjectName("LoginCard")
self.card.setMinimumWidth(400)
self.card.setMaximumWidth(520)
self.card.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
card_layout = QVBoxLayout(self.card)
card_layout.setSizeConstraint(QLayout.SizeConstraint.SetMinimumSize)
card_layout.setContentsMargins(34, 30, 34, 30)
card_layout.setSpacing(14)
title = QLabel("欢迎回来")
title.setProperty("role", "pageTitle")
card_layout.addWidget(title)
subtitle = QLabel("使用医生账号登录工作站")
subtitle.setProperty("role", "muted")
card_layout.addWidget(subtitle)
card_layout.addSpacing(6)
self.error_banner = MessageBanner()
card_layout.addWidget(self.error_banner)
account_label = QLabel("账号")
account_label.setStyleSheet("font-weight:600;")
card_layout.addWidget(account_label)
self.account_edit = QLineEdit()
self.account_edit.setPlaceholderText("手机号或工作账号")
self.account_edit.setClearButtonEnabled(True)
self.account_edit.setAccessibleName("登录账号")
card_layout.addWidget(self.account_edit)
password_label = QLabel("密码")
password_label.setStyleSheet("font-weight:600;")
card_layout.addWidget(password_label)
password_row = QHBoxLayout()
password_row.setSpacing(6)
self.password_edit = QLineEdit()
self.password_edit.setEchoMode(QLineEdit.EchoMode.Password)
self.password_edit.setPlaceholderText("请输入密码")
self.password_edit.setAccessibleName("登录密码")
self.password_edit.returnPressed.connect(self.submit)
password_row.addWidget(self.password_edit, 1)
self.reveal_button = QToolButton()
self.reveal_button.setText("显示")
self.reveal_button.setCheckable(True)
self.reveal_button.setToolTip("显示或隐藏密码")
self.reveal_button.toggled.connect(self._toggle_password)
password_row.addWidget(self.reveal_button)
card_layout.addLayout(password_row)
choices = QHBoxLayout()
self.remember_check = _VisibleCheckBox("记住密码")
self.remember_check.setToolTip("密码使用系统安全凭据或 Windows DPAPI 加密,不保存明文")
choices.addWidget(self.remember_check)
choices.addStretch(1)
self.demo_check = _VisibleCheckBox("演示模式")
self.demo_check.setEnabled(self.demo_repository is not None)
if self.demo_repository is None:
self.demo_check.setToolTip("当前未配置演示数据")
self.demo_check.toggled.connect(self._on_demo_toggled)
choices.addWidget(self.demo_check)
card_layout.addLayout(choices)
self.login_button = QPushButton("登录工作站")
self.login_button.setProperty("variant", "primary")
self.login_button.setMinimumHeight(42)
self.login_button.clicked.connect(self.submit)
card_layout.addWidget(self.login_button)
self.server_toggle = QPushButton("服务器设置 +")
self.server_toggle.setProperty("variant", "ghost")
self.server_toggle.setCheckable(True)
self.server_toggle.clicked.connect(self._toggle_server_panel)
card_layout.addWidget(self.server_toggle)
self.server_panel = QFrame()
self.server_panel.setObjectName("SubtleCard")
self.server_panel.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Minimum,
)
server_layout = QVBoxLayout(self.server_panel)
server_layout.setSizeConstraint(QLayout.SizeConstraint.SetMinimumSize)
server_layout.setContentsMargins(14, 12, 14, 12)
server_layout.setSpacing(8)
self.server_url_label = QLabel("服务器地址")
self.server_url_label.setObjectName("ServerUrlLabel")
server_layout.addWidget(self.server_url_label)
self.server_url_edit = QLineEdit()
self.server_url_edit.setPlaceholderText("例如 https://api.example.com")
self.server_url_edit.setMinimumHeight(38)
self.server_url_edit.setAccessibleName("服务器地址")
server_layout.addWidget(self.server_url_edit)
timeout_row = QHBoxLayout()
timeout_row.setSpacing(10)
self.timeout_label = QLabel("读取超时")
timeout_row.addWidget(self.timeout_label)
self.timeout_spin = QSpinBox()
self.timeout_spin.setRange(10, 180)
self.timeout_spin.setSuffix(" 秒")
self.timeout_spin.setValue(60)
self.timeout_spin.setFixedWidth(128)
timeout_row.addWidget(self.timeout_spin)
timeout_row.addStretch(1)
self.save_server_button = QPushButton("保存设置")
self.save_server_button.setProperty("variant", "secondary")
self.save_server_button.setMinimumWidth(112)
self.save_server_button.clicked.connect(self._save_server_settings)
timeout_row.addWidget(self.save_server_button)
server_layout.addLayout(timeout_row)
self.allow_self_signed_check = _VisibleCheckBox("信任自签名证书(仅内网调试)")
self.allow_self_signed_check.setObjectName("AllowSelfSignedCertificate")
self.allow_self_signed_check.setToolTip(
"关闭 HTTPS 证书校验会降低连接安全性,仅用于可信内网的自签名服务器"
)
server_layout.addWidget(self.allow_self_signed_check)
self.ssl_warning = QLabel(
"启用后将不再验证服务器身份,请勿用于公网或正式生产环境。"
)
self.ssl_warning.setObjectName("SelfSignedCertificateWarning")
self.ssl_warning.setProperty("role", "danger")
self.ssl_warning.setWordWrap(True)
self.ssl_warning.setVisible(False)
self.allow_self_signed_check.toggled.connect(self.ssl_warning.setVisible)
server_layout.addWidget(self.ssl_warning)
self.server_hint = QLabel("填写管理员提供的 HTTPS 域名,程序会自动追加 /adminapi。")
self.server_hint.setProperty("role", "muted")
self.server_hint.setWordWrap(True)
server_layout.addWidget(self.server_hint)
self.server_panel.setVisible(False)
card_layout.addWidget(self.server_panel)
footnote = QLabel("登录即表示你同意遵守机构的数据安全与隐私规范。")
footnote.setProperty("role", "muted")
footnote.setWordWrap(True)
card_layout.addWidget(footnote)
outer.addWidget(self.card, 0, Qt.AlignmentFlag.AlignHCenter)
outer.addStretch(1)
self.busy_overlay = BusyOverlay(self.card, "正在验证账号…")
return area
def _restore_settings(self) -> None:
configured_account = getattr(self.config, "remembered_account", "")
remembered = str(self.settings.value("auth/remembered_account", configured_account) or "")
configured_url = getattr(self.config, "base_url", "") or getattr(
self.config, "api_base_url", ""
)
self.server_url_edit.setText(
str(self.settings.value("server/base_url", configured_url) or "")
)
try:
configured_timeout = getattr(self.config, "request_timeout", 60)
timeout = int(
self.settings.value("server/read_timeout", configured_timeout) or configured_timeout
)
except (TypeError, ValueError):
timeout = 60
self.timeout_spin.setValue(max(10, min(180, timeout)))
configured_verify_ssl = _setting_bool(getattr(self.config, "verify_ssl", True), True)
verify_ssl = _setting_bool(
self.settings.value("server/verify_ssl", configured_verify_ssl),
configured_verify_ssl,
)
self.allow_self_signed_check.setChecked(not verify_ssl)
if self.demo_repository is not None and bool(getattr(self.config, "demo_mode", False)):
self.demo_check.setChecked(True)
self.account_edit.setText(remembered)
self.restore_remembered_credentials()
if remembered:
self.password_edit.setFocus()
else:
self.account_edit.setFocus()
def _credential_scope(self) -> str:
if hasattr(self, "server_url_edit"):
scope = self.server_url_edit.text().strip()
if scope:
return scope.rstrip("/")
return str(
getattr(self.config, "base_url", "")
or getattr(self.config, "api_base_url", "")
or ""
).strip().rstrip("/")
def restore_remembered_credentials(self) -> None:
"""Restore a password from the OS keyring without touching config files."""
configured_account = getattr(self.config, "remembered_account", "")
account = str(
self.settings.value("auth/remembered_account", configured_account) or ""
).strip()
scope = self._credential_scope()
password = ""
should_restore = _setting_bool(
self.settings.value("auth/remember_password", False),
False,
)
loader = getattr(self.credential_store, "load_password", None)
if should_restore and account and scope and callable(loader):
try:
password = str(loader(account=account, scope=scope) or "")
except Exception:
password = ""
self.account_edit.setText(account)
self.password_edit.setText(password)
self.remember_check.setChecked(bool(password))
self._restored_account = account if password else ""
self._restored_scope = scope if password else ""
def _toggle_password(self, visible: bool) -> None:
self.password_edit.setEchoMode(
QLineEdit.EchoMode.Normal if visible else QLineEdit.EchoMode.Password
)
self.reveal_button.setText("隐藏" if visible else "显示")
def _on_demo_toggled(self, enabled: bool) -> None:
self.active_repository = self.demo_repository if enabled else self.repository
self.server_toggle.setEnabled(not enabled and not self._loading)
self.demo_mode_changed.emit(enabled)
self._emit_config_update(demo_mode=enabled)
self.error_banner.clear()
if enabled:
self.account_edit.setPlaceholderText("可留空,使用演示医生")
self.password_edit.setPlaceholderText("可留空")
else:
self.account_edit.setPlaceholderText("手机号或工作账号")
self.password_edit.setPlaceholderText("请输入密码")
def _toggle_server_panel(self, expanded: bool) -> None:
self.server_panel.setVisible(expanded)
self.server_toggle.setText("服务器设置 -" if expanded else "服务器设置 +")
self.server_panel.updateGeometry()
self.card.updateGeometry()
if expanded:
QTimer.singleShot(
0,
lambda: self.login_scroll.ensureWidgetVisible(self.server_panel, 0, 24),
)
def _save_server_settings(self) -> None:
self._apply_server_settings(announce=True)
def _apply_server_settings(self, *, announce: bool) -> bool:
base_url = self.server_url_edit.text().strip().rstrip("/")
if base_url and not base_url.startswith(
("https://", "http://localhost", "http://127.0.0.1")
):
self.error_banner.show_message(
"服务地址需使用 HTTPS;本机调试可使用 localhost。", "warning"
)
return False
values = {
"base_url": base_url,
"read_timeout": self.timeout_spin.value(),
"api_base_url": base_url,
"request_timeout": self.timeout_spin.value(),
"verify_ssl": not self.allow_self_signed_check.isChecked(),
}
self.settings.setValue("server/base_url", base_url)
self.settings.setValue("server/read_timeout", self.timeout_spin.value())
self.settings.setValue("server/verify_ssl", values["verify_ssl"])
self.settings.sync()
self.server_settings_changed.emit(values)
self._emit_config_update(
api_base_url=base_url,
request_timeout=self.timeout_spin.value(),
verify_ssl=values["verify_ssl"],
)
if not announce:
return True
if values["verify_ssl"]:
self.error_banner.show_message("服务器设置已保存,将在连接时生效。", "success")
else:
self.error_banner.show_message(
"服务器设置已保存:证书校验已关闭,仅限可信内网调试。", "warning"
)
return True
def _emit_config_update(self, **changes: Any) -> None:
updater = getattr(self.config, "with_updates", None)
if callable(updater):
try:
self.config = updater(**changes)
except (TypeError, ValueError):
self.config_changed.emit(changes)
else:
self.config_changed.emit(self.config)
return
self.config_changed.emit(changes)
def submit(self) -> None:
if self._loading:
return
demo_mode = self.demo_check.isChecked()
remember_account = self.remember_check.isChecked()
account = self.account_edit.text().strip()
password = self.password_edit.text()
if demo_mode:
account = account or str(getattr(self.active_repository, "DEMO_ACCOUNT", "doctor"))
password = password or str(
getattr(self.active_repository, "DEMO_PASSWORD", "doctor123")
)
if not account:
self.error_banner.show_message("请输入登录账号。", "warning")
self.account_edit.setFocus()
return
if not password:
self.error_banner.show_message("请输入密码。", "warning")
self.password_edit.setFocus()
return
if not demo_mode and not self._apply_server_settings(announce=False):
return
repository = self.active_repository
if repository is None:
self.error_banner.show_message("演示服务尚未配置。", "warning")
return
self.error_banner.clear()
self._set_loading(True)
def authenticate() -> dict[str, Any]:
session = invoke(
repository,
"login",
account=account,
password=password,
remember_account=remember_account,
)
user = invoke(repository, "get_current_user")
return {
"session": session,
"user": user,
"repository": repository,
"demo_mode": demo_mode,
"remember_account": remember_account,
}
run_async(
authenticate,
on_success=lambda payload: self._on_login_success(
payload,
account,
remember_account,
password,
),
on_error=self._on_login_error,
on_finished=lambda: self._set_loading(False),
)
def _set_loading(
self,
loading: bool,
*,
button_text: str = "正在登录…",
overlay_text: str = "正在验证账号…",
) -> None:
self._loading = loading
self.login_button.setEnabled(not loading)
self.account_edit.setEnabled(not loading)
self.password_edit.setEnabled(not loading)
self.remember_check.setEnabled(not loading)
self.reveal_button.setEnabled(not loading)
self.demo_check.setEnabled(not loading and self.demo_repository is not None)
self.server_toggle.setEnabled(not loading and not self.demo_check.isChecked())
self.server_panel.setEnabled(not loading)
self.server_url_edit.setEnabled(not loading)
self.timeout_spin.setEnabled(not loading)
self.allow_self_signed_check.setEnabled(not loading)
self.save_server_button.setEnabled(not loading)
self.login_button.setText(button_text if loading else "登录工作站")
self.busy_overlay.set_message(overlay_text)
self.busy_overlay.setVisible(loading)
if loading:
self.busy_overlay.raise_()
def set_session_restore_pending(self, pending: bool) -> None:
"""Block manual login controls while persisted-token validation runs."""
if pending:
self.error_banner.clear()
self._set_loading(
pending,
button_text="正在恢复登录…",
overlay_text="正在验证已保存的登录状态…",
)
def _on_login_success(
self,
payload: dict[str, Any],
account: str,
remember_account: bool | None = None,
password: str | None = None,
) -> None:
if remember_account is None:
remember_account = self.remember_check.isChecked()
scope = self._credential_scope()
is_demo = bool(payload.get("demo_mode"))
password_saved = False
clearer = getattr(self.credential_store, "clear_password", None)
if callable(clearer) and self._restored_account and (
not remember_account
or self._restored_account != account
or self._restored_scope != scope
):
with suppress(Exception):
clearer(account=self._restored_account, scope=self._restored_scope)
if remember_account:
self.settings.setValue("auth/remembered_account", account)
saver = getattr(self.credential_store, "save_password", None)
if not is_demo and password and scope and callable(saver):
try:
password_saved = bool(saver(password, account=account, scope=scope))
except Exception:
password_saved = False
else:
self.settings.remove("auth/remembered_account")
if callable(clearer):
with suppress(Exception):
clearer(account=account, scope=scope)
self.settings.setValue("auth/remember_password", password_saved)
self.settings.sync()
self._emit_config_update(remembered_account=account if remember_account else "")
self._restored_account = account if password_saved else ""
self._restored_scope = scope if password_saved else ""
self.password_edit.clear()
self.authenticated_user = payload.get("user")
self.login_succeeded.emit(payload)
self.authenticated.emit(payload.get("session"))
def _on_login_error(self, error: Exception) -> None:
error_text = str(error).lower()
if (
"certificate_verify_failed" in error_text
or "self-signed certificate" in error_text
):
self.server_toggle.setChecked(True)
self._toggle_server_panel(True)
message = friendly_error(error)
self.error_banner.show_message(message, "danger")
self.login_failed.emit(message)
self.password_edit.selectAll()
self.password_edit.setFocus()
__all__ = ["LoginWindow"]