704 lines
28 KiB
Python
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"]
|