This commit is contained in:
Your Name
2026-08-11 17:39:41 +08:00
parent cfe4c82c90
commit 25467b9d91
350 changed files with 201115 additions and 132208 deletions
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -30,12 +30,12 @@ APP_NAME = "ZhenyangDoctor"
APP_AUTHOR = "Zhenyangtang"
def _as_bool(value: str | bool | None, default: bool) -> bool:
def _as_bool(value: Any, default: bool) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on", "y"}
return str(value).strip().lower() in {"1", "true", "yes", "on", "y"}
def _safe_timeout(value: str | int | float | None, default: float = 30.0) -> float:
@@ -148,6 +148,8 @@ class AppConfig:
clean.pop("video_mode", None)
if "request_timeout" in clean:
clean["request_timeout"] = _safe_timeout(clean["request_timeout"])
if "verify_ssl" in clean:
clean["verify_ssl"] = _as_bool(clean["verify_ssl"], True)
return replace(self, **clean)
def save_preferences(self) -> None:
@@ -169,4 +171,6 @@ class AppConfig:
raise ValueError("视频模式只能是 embedded 或 browser")
if "request_timeout" in changes:
changes["request_timeout"] = _safe_timeout(changes["request_timeout"])
if "verify_ssl" in changes:
changes["verify_ssl"] = _as_bool(changes["verify_ssl"], True)
return replace(self, **changes)
@@ -6,7 +6,7 @@ import time
from collections.abc import Callable, Mapping
from threading import RLock
from typing import Any
from urllib.parse import urlsplit, urlunsplit
from urllib.parse import urljoin, urlsplit, urlunsplit
import httpx
@@ -156,6 +156,66 @@ class ApiClient:
headers=headers,
)
def get_bytes(self, url: str, *, max_bytes: int = 5 * 1024 * 1024) -> bytes:
"""Download a public binary asset without applying the JSON envelope contract."""
if max_bytes <= 0:
raise ValueError("max_bytes must be positive")
candidate = url.strip()
if not candidate:
raise ValueError("url must not be empty")
target = urljoin(self.base_url, candidate)
parsed = urlsplit(target)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("url must resolve to an absolute http(s) URL")
try:
response = self._client.get(
target,
headers={"Accept": "image/*,application/octet-stream;q=0.8"},
timeout=self.timeout,
follow_redirects=True,
)
except httpx.TimeoutException as exc:
raise ApiTimeoutError("Image download timed out", data={"url": target}) from exc
except httpx.RequestError as exc:
raise ApiTransportError(
f"Image download failed: {exc}", data={"url": target}
) from exc
request_id = self._request_id(response)
if not 200 <= response.status_code < 300:
raise ApiHttpError(
f"Image server returned HTTP {response.status_code}",
status_code=response.status_code,
request_id=request_id,
)
length = response.headers.get("content-length")
if length:
try:
if int(length) > max_bytes:
raise ApiProtocolError(
"Image response is too large",
data={"max_bytes": max_bytes, "content_length": int(length)},
status_code=response.status_code,
request_id=request_id,
)
except ValueError:
pass
content = response.content
if not content:
raise ApiProtocolError(
"Image response is empty",
status_code=response.status_code,
request_id=request_id,
)
if len(content) > max_bytes:
raise ApiProtocolError(
"Image response is too large",
data={"max_bytes": max_bytes, "content_length": len(content)},
status_code=response.status_code,
request_id=request_id,
)
return content
def request(
self,
method: str,
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
@@ -42,9 +42,11 @@ class FlowLayout(QLayout):
self._items: list[QLayoutItem] = []
self._horizontal_spacing = horizontal_spacing
self._vertical_spacing = vertical_spacing
self._height_cache: dict[int, int] = {}
self.setContentsMargins(0, 0, 0, 0)
def addItem(self, item: QLayoutItem) -> None: # noqa: N802 - Qt virtual
self._height_cache.clear()
self._items.append(item)
def count(self) -> int:
@@ -54,6 +56,7 @@ class FlowLayout(QLayout):
return self._items[index] if 0 <= index < len(self._items) else None
def takeAt(self, index: int) -> QLayoutItem | None: # noqa: N802 - Qt virtual
self._height_cache.clear()
return self._items.pop(index) if 0 <= index < len(self._items) else None
def expandingDirections(self) -> Qt.Orientations: # noqa: N802 - Qt virtual
@@ -63,12 +66,22 @@ class FlowLayout(QLayout):
return True
def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt virtual
return self._do_layout(QRect(0, 0, max(0, width), 0), test_only=True)
key = max(0, int(width))
cached = self._height_cache.get(key)
if cached is not None:
return cached
height = self._do_layout(QRect(0, 0, key, 0), test_only=True)
self._height_cache[key] = height
return height
def setGeometry(self, rect: QRect) -> None: # noqa: N802 - Qt virtual
super().setGeometry(rect)
self._do_layout(rect, test_only=False)
def invalidate(self) -> None: # noqa: N802 - Qt virtual
self._height_cache.clear()
super().invalidate()
def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual
return self.minimumSize()
File diff suppressed because it is too large Load Diff
@@ -128,49 +128,48 @@ def open_safe_http_url(target: str) -> bool:
_INLINE_PLAYER_QSS = """
QWidget#DiagnosisInlineRecordingPlayer {
background: #111827;
border: 1px solid #1F2937;
background: #101626;
border: 1px solid #29334F;
border-radius: 6px;
}
QFrame#DiagnosisInlineRecordingSurface {
background: #05070A;
background: #080B14;
border: 0;
border-radius: 5px 5px 0 0;
}
QLabel#DiagnosisInlineRecordingPlaceholder {
color: #D1D5DB;
color: #9AA7C0;
font-size: 12px;
line-height: 1.4;
}
QLabel#DiagnosisInlineRecordingTime {
color: #CBD5E1;
color: #9AA7C0;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
QPushButton[recordingControl="true"] {
min-height: 24px;
max-height: 24px;
padding: 0 8px;
color: #E5E7EB;
background: #1F2937;
border: 1px solid #374151;
color: #EEF2FF;
background: #151D31;
border: 1px solid #29334F;
border-radius: 4px;
font-size: 11px;
}
QPushButton[recordingControl="true"]:hover,
QPushButton[recordingControl="true"]:focus {
color: #FFFFFF;
background: #2563EB;
border-color: #2563EB;
color: #EEF2FF;
background: #6675F5;
border-color: #6675F5;
}
QPushButton[recordingControl="true"]:disabled { color: #6B7280; }
QSlider::groove:horizontal { height: 3px; background: #4B5563; border-radius: 1px; }
QSlider::sub-page:horizontal { background: #3B82F6; border-radius: 1px; }
QPushButton[recordingControl="true"]:disabled { color: #9AA7C0; background:#101626; }
QSlider::groove:horizontal { height: 3px; background: #29334F; border-radius: 1px; }
QSlider::sub-page:horizontal { background: #6675F5; border-radius: 1px; }
QSlider::handle:horizontal {
width: 10px;
margin: -4px 0;
background: #F8FAFC;
border: 1px solid #94A3B8;
background: #EEF2FF;
border: 1px solid #9AA7C0;
border-radius: 5px;
}
"""
@@ -216,20 +215,11 @@ class InlineRecordingPlayer(QWidget):
self._surface_stack.setContentsMargins(0, 0, 0, 0)
self._surface_stack.setStackingMode(QStackedLayout.StackingMode.StackOne)
if MULTIMEDIA_AVAILABLE:
self.video = QVideoWidget()
self.video.setObjectName("DiagnosisInlineRecordingVideo")
self._surface_stack.addWidget(self.video)
self.audio_output = QAudioOutput(self)
self.player = QMediaPlayer(self)
self.player.setAudioOutput(self.audio_output)
self.player.setVideoOutput(self.video)
self.player.positionChanged.connect(self._position_changed)
self.player.durationChanged.connect(self._duration_changed)
self.player.playbackStateChanged.connect(self._state_changed)
self.player.errorOccurred.connect(self._player_error)
else:
self.video = None
# A diagnosis can contain dozens of call records and is rendered in two
# tables. Creating a native player for every row exhausts the Windows
# multimedia backend and can terminate the process with 0xC0000005.
# Keep the backend genuinely lazy and create one only on first playback.
self.video = None
self.placeholder = QLabel(
"预览待加载\n点击播放开始加载"
@@ -274,7 +264,7 @@ class InlineRecordingPlayer(QWidget):
root.addLayout(controls)
valid = self.url is not None
self.play_button.setEnabled(valid and self.player is not None)
self.play_button.setEnabled(valid and MULTIMEDIA_AVAILABLE)
external.setEnabled(valid)
fallback.setEnabled(valid)
if not valid:
@@ -292,14 +282,55 @@ class InlineRecordingPlayer(QWidget):
current = getattr(owner, "_media_generation", self._owner_generation)
return current == self._owner_generation
def _ensure_player(self) -> bool:
"""Create the native multimedia objects on first playback only."""
if self.player is not None:
return True
if (
not MULTIMEDIA_AVAILABLE
or QMediaPlayer is None
or QAudioOutput is None
or QVideoWidget is None
):
return False
try:
video = QVideoWidget()
video.setObjectName("DiagnosisInlineRecordingVideo")
audio_output = QAudioOutput(self)
player = QMediaPlayer(self)
player.setAudioOutput(audio_output)
player.setVideoOutput(video)
player.positionChanged.connect(self._position_changed)
player.durationChanged.connect(self._duration_changed)
player.playbackStateChanged.connect(self._state_changed)
player.errorOccurred.connect(self._player_error)
self.video = video
self.audio_output = audio_output
self.player = player
self._surface_stack.addWidget(video)
except (RuntimeError, TypeError):
self.video = None
self.audio_output = None
self.player = None
self.placeholder.setText(
"系统媒体组件初始化失败\n可安全外部打开或使用独立窗口"
)
self._surface_stack.setCurrentWidget(self.placeholder)
self.play_button.setEnabled(False)
return False
return True
def _attach_source(self) -> bool:
if self.player is None or self.url is None:
if self.url is None:
return False
if not self._owner_is_current():
self.play_button.setEnabled(False)
self.placeholder.setText("该回放列表已刷新\n请在最新记录中播放")
self._surface_stack.setCurrentWidget(self.placeholder)
return False
if not self._ensure_player() or self.player is None:
return False
if not self._source_attached:
self.player.setSource(self.url)
self._source_attached = True
@@ -414,11 +445,11 @@ class RecordingPlaybackCell(QWidget):
separator = QFrame()
separator.setObjectName("DiagnosisRecordingAlternateSeparator")
separator.setFrameShape(QFrame.Shape.HLine)
separator.setStyleSheet("color:#E5E7EB;")
separator.setStyleSheet("color:#29334F;")
layout.addWidget(separator)
label = QLabel("备用地址")
label.setObjectName("DiagnosisRecordingAlternateLabel")
label.setStyleSheet("color:#909399; font-size:12px;")
label.setStyleSheet("color:#9AA7C0; font-size:12px;")
layout.addWidget(label)
links = QHBoxLayout()
links.setContentsMargins(0, 0, 0, 0)
@@ -438,7 +469,7 @@ class RecordingPlaybackCell(QWidget):
layout.addLayout(links)
self.link_status = QLabel("")
self.link_status.setObjectName("DiagnosisRecordingLinkStatus")
self.link_status.setStyleSheet("color:#DC2626; font-size:11px;")
self.link_status.setStyleSheet("color:#F07886; font-size:11px;")
self.link_status.setWordWrap(True)
self.link_status.hide()
layout.addWidget(self.link_status)
@@ -1,5 +1,5 @@
"""Reusable doctor-workstation dialogs."""
from .diagnosis import DiagnosisDialog
from .diagnosis import DiagnosisDialog, OrderDetailDrawer, present_order_detail
__all__ = ["DiagnosisDialog"]
__all__ = ["DiagnosisDialog", "OrderDetailDrawer", "present_order_detail"]
+352 -128
View File
@@ -8,14 +8,13 @@ from contextlib import suppress
from typing import Any
from PySide6.QtCore import QDate, QDateTime, QEvent, QPoint, QRect, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QColor, QDesktopServices
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import (
QAbstractItemView,
QBoxLayout,
QDialog,
QFileDialog,
QFrame,
QGraphicsDropShadowEffect,
QGridLayout,
QHBoxLayout,
QInputDialog,
@@ -173,7 +172,8 @@ _INTEGER_FIELDS = {
"status",
"show_card",
}
_FLOAT_FIELDS = {"height", "weight", "fasting_blood_sugar"}
# fasting_blood_sugar stays free text (supports ranges like "6-9").
_FLOAT_FIELDS = {"height", "weight"}
_PATIENT_BASIC_FIELDS = {"patient_name", "phone", "id_card", "gender", "age"}
_ORDER_OFFSET_HELP = (
@@ -184,75 +184,74 @@ _ORDER_OFFSET_HELP = (
_ORDER_DETAIL_QSS = """
QDialog#DiagnosisOrderDetailOverlay { background: transparent; }
QFrame#DiagnosisOrderDetailScrim { background: rgba(15, 23, 42, 0.32); border: 0; }
QFrame#DiagnosisOrderDetailScrim { background: rgba(8, 11, 20, 0.78); border: 0; }
QFrame#DiagnosisOrderDetailDrawer {
background: #F6F8FB;
background: #F5F7FB;
border: 0;
border-left: 1px solid #DDE3EA;
border-left: 1px solid #D8DEEA;
}
QFrame#DiagnosisOrderDetailHeader {
background: #FFFFFF;
border: 0;
border-bottom: 1px solid #E5EAF0;
border-bottom: 1px solid #D8DEEA;
}
QLabel#DiagnosisOrderDetailTitle { color: #172033; font-size: 19px; font-weight: 650; }
QLabel#DiagnosisOrderDetailMeta { color: #697386; font-size: 12px; }
QLabel#DiagnosisOrderDetailMeta { color: #667085; font-size: 12px; }
QLabel#DiagnosisOrderReadonlyBadge {
color: #526171;
background: #EEF2F6;
border: 1px solid #D8E0E8;
color: #667085;
background: #F7F8FC;
border: 1px solid #D8DEEA;
border-radius: 4px;
padding: 3px 7px;
font-size: 11px;
font-weight: 600;
}
QScrollArea#DiagnosisOrderDetailScroll { border: 0; background: #F6F8FB; }
QScrollArea#DiagnosisOrderDetailScroll > QWidget > QWidget { background: #F6F8FB; }
QScrollArea#DiagnosisOrderDetailScroll { border: 0; background: #F5F7FB; }
QScrollArea#DiagnosisOrderDetailScroll > QWidget > QWidget { background: #F5F7FB; }
QFrame[orderAmountCard="true"] {
background: #FFFFFF;
border: 1px solid #E4E9EF;
border: 1px solid #D8DEEA;
border-radius: 7px;
}
QLabel[orderAmountTitle="true"] { color: #7A8594; font-size: 11px; font-weight: 550; }
QLabel[orderAmountTitle="true"] { color: #667085; font-size: 11px; font-weight: 550; }
QLabel[orderAmountValue="true"] {
color: #172033;
font-size: 18px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
QLabel[orderAmountTone="danger"] { color: #D84A4A; }
QLabel[orderAmountTone="success"] { color: #28845A; }
QLabel[orderAmountTone="warning"] { color: #B46B16; }
QLabel[orderAmountTone="danger"] { color: #C43E55; }
QLabel[orderAmountTone="success"] { color: #16876C; }
QLabel[orderAmountTone="warning"] { color: #9A6813; }
QFrame[orderDetailSection="true"] {
background: #FFFFFF;
border: 1px solid #E2E7ED;
border: 1px solid #D8DEEA;
border-radius: 7px;
}
QLabel[orderSectionTitle="true"] { color: #253047; font-size: 15px; font-weight: 650; }
QLabel[orderSectionHint="true"] { color: #8A94A3; font-size: 11px; }
QLabel[orderSectionTitle="true"] { color: #172033; font-size: 15px; font-weight: 650; }
QLabel[orderSectionHint="true"] { color: #667085; font-size: 11px; }
QFrame[orderField="true"] {
background: #FAFBFC;
border: 1px solid #EDF0F4;
background: #F7F8FC;
border: 1px solid #D8DEEA;
border-radius: 5px;
}
QLabel[orderFieldLabel="true"] { color: #7A8594; font-size: 11px; }
QLabel[orderFieldValue="true"] { color: #263247; font-size: 13px; }
QLabel[orderFieldLabel="true"] { color: #667085; font-size: 11px; }
QLabel[orderFieldValue="true"] { color: #172033; font-size: 13px; }
QLabel[orderEmptyState="true"] {
color: #9099A8;
background: #FAFBFC;
border: 1px dashed #DDE3EA;
color: #667085;
background: #F7F8FC;
border: 1px dashed #D8DEEA;
border-radius: 5px;
padding: 18px 12px;
font-size: 12px;
}
QFrame#DiagnosisOrderTimelineItem { border: 0; border-left: 2px solid #D8E0E8; }
QLabel#DiagnosisOrderTimelineTime { color: #7A8594; font-size: 11px; }
QLabel#DiagnosisOrderTimelineTitle { color: #253047; font-size: 12px; font-weight: 600; }
QLabel#DiagnosisOrderTimelineBody { color: #5E6979; font-size: 12px; }
QFrame#DiagnosisOrderTimelineItem { border: 0; border-left: 2px solid #D8DEEA; }
QLabel#DiagnosisOrderTimelineTime { color: #667085; font-size: 11px; }
QLabel#DiagnosisOrderTimelineTitle { color: #172033; font-size: 12px; font-weight: 600; }
QLabel#DiagnosisOrderTimelineBody { color: #667085; font-size: 12px; }
QFrame#DiagnosisOrderDetailFooter {
background: #FFFFFF;
border: 0;
border-top: 1px solid #E5EAF0;
border-top: 1px solid #D8DEEA;
}
"""
@@ -265,34 +264,38 @@ _FORM_SECTIONS: tuple[tuple[str, tuple[tuple[tuple[str, str, int, str], ...], ..
("姓名", "patient_name", 12, "line"),
("身份证号", "id_card", 12, "line"),
),
(("手机号", "phone", 24, "line"),),
(
("手机号", "phone", 12, "line"),
("性别", "gender", 12, "gender"),
("年龄", "age", 12, "age_number"),
),
(("年龄", "age", 12, "age_number"),),
),
),
(
"生命体征",
(
(
("婚姻", "marital_status", 8, "marital"),
("身高", "height", 8, "height_number"),
("体重", "weight", 8, "weight_number"),
("婚姻", "marital_status", 12, "marital"),
("身高", "height", 12, "height_number"),
),
(
("地区", "region", 8, "line"),
("高压", "systolic_pressure", 8, "systolic_number"),
("低压", "diastolic_pressure", 8, "diastolic_number"),
("体重", "weight", 12, "weight_number"),
("地区", "region", 12, "line"),
),
(
("高压", "systolic_pressure", 12, "systolic_number"),
("低压", "diastolic_pressure", 12, "diastolic_number"),
),
(
("空腹血糖", "fasting_blood_sugar", 12, "line"),
("诊断类型", "diagnosis_type", 12, "diagnosis_type"),
),
(("空腹血糖", "fasting_blood_sugar", 8, "line"),),
(("诊断类型", "diagnosis_type", 12, "diagnosis_type"),),
(
("状态", "status", 12, "status"),
("渠道", "create_source", 12, "create_source"),
),
(("统计端就诊卡", "show_card", 12, "show_card"),),
(("在用药物", "current_medications", 12, "textarea3"),),
(("在用药物", "current_medications", 24, "textarea3"),),
),
),
(
@@ -311,18 +314,18 @@ _FORM_SECTIONS: tuple[tuple[str, tuple[tuple[tuple[str, str, int, str], ...], ..
(
(("口腔感觉", "appetite", 24, "appetite_choices"),),
(("每日饮水量", "water_intake", 24, "water_intake_choices"),),
(("近月体重变化", "weight_change", 24, "weight_change_choices"),),
(("脂肪肝", "fatty_liver_degree", 24, "fatty_liver_degree_choices"),),
(("饮食", "diet_condition", 24, "diet_condition_choices"),),
(("肢体", "body_feeling", 24, "body_feeling_choices"),),
(("睡眠", "sleep_condition", 24, "sleep_condition_choices"),),
(("眼睛", "eye_condition", 24, "eye_condition_choices"),),
(("头部", "head_feeling", 24, "head_feeling_choices"),),
(("出汗", "sweat_condition", 24, "sweat_condition_choices"),),
(("皮肤", "skin_condition", 24, "skin_condition_choices"),),
(("小便", "urine_condition", 24, "urine_condition_choices"),),
(("大便", "stool_condition", 24, "stool_condition_choices"),),
(("腰肾", "kidney_condition", 24, "kidney_condition_choices"),),
(("一个月体重变化", "weight_change", 24, "weight_change_choices"),),
(("脂肪肝程度", "fatty_liver_degree", 24, "fatty_liver_degree_choices"),),
(("饮食情况", "diet_condition", 24, "diet_condition_choices"),),
(("肢体感觉", "body_feeling", 24, "body_feeling_choices"),),
(("睡眠情况", "sleep_condition", 24, "sleep_condition_choices"),),
(("眼睛情况", "eye_condition", 24, "eye_condition_choices"),),
(("头部感觉", "head_feeling", 24, "head_feeling_choices"),),
(("出汗情况", "sweat_condition", 24, "sweat_condition_choices"),),
(("皮肤情况", "skin_condition", 24, "skin_condition_choices"),),
(("小便情况", "urine_condition", 24, "urine_condition_choices"),),
(("大便情况", "stool_condition", 24, "stool_condition_choices"),),
(("腰肾情况", "kidney_condition", 24, "kidney_condition_choices"),),
(("其他补充", "symptoms", 24, "textarea3"),),
),
),
@@ -331,14 +334,14 @@ _FORM_SECTIONS: tuple[tuple[str, tuple[tuple[tuple[str, str, int, str], ...], ..
"其他病史",
(
(
("外伤", "trauma_history", 8, "boolean"),
("手术", "surgery_history", 8, "boolean"),
("过敏", "allergy_history", 8, "boolean"),
("外伤", "trauma_history", 12, "boolean"),
("手术", "surgery_history", 12, "boolean"),
),
(
("家族", "family_history", 12, "boolean"),
("妊娠", "pregnancy_history", 12, "boolean"),
("过敏史", "allergy_history", 12, "boolean"),
("家族史", "family_history", 12, "boolean"),
),
(("妊娠史", "pregnancy_history", 12, "boolean"),),
),
),
("诊断信息", ((("病史补充", "remark", 24, "textarea2"),),)),
@@ -710,7 +713,8 @@ class OrderDetailDrawer(QDialog):
self.drawer_panel.setFixedWidth(max(1, width))
def eventFilter(self, watched: object, event: QEvent) -> bool: # noqa: N802 - Qt API
if watched is self._owner and event.type() in {
owner = getattr(self, "_owner", None)
if watched is owner and event.type() in {
QEvent.Type.Move,
QEvent.Type.Resize,
QEvent.Type.Show,
@@ -734,9 +738,9 @@ class DiagnosisDialog(QDialog):
"""Diagnosis readonly page plus edit/view-only right drawer.
The public ``open_for`` contract and all repository calls remain compatible
with the earlier dialog. ``editable=False`` opens the standalone readonly
card flow; callers that explicitly need the admin ``viewOnly`` drawer can
pass ``view_only=True`` or call :meth:`open_view_only`.
with the earlier dialog. ``editable=False`` alone keeps the standalone
readonly card flow; list/detail 查看 should call :meth:`open_view_only`
so it matches admin ``openViewOnly`` (same 病历 form, chips stay selected).
"""
saved = Signal()
@@ -962,8 +966,8 @@ class DiagnosisDialog(QDialog):
back.setCursor(Qt.CursorShape.PointingHandCursor)
back.setStyleSheet(
"QPushButton{height:32px;padding:0 8px;border:0;background:transparent;"
"color:#409EFF;font-size:13px;font-weight:500;}"
"QPushButton:hover,QPushButton:focus{background:#ECF5FF;border-radius:6px;}"
"color:#78A7FF;font-size:13px;font-weight:500;}"
"QPushButton:hover,QPushButton:focus{background:#1B2440;border-radius:6px;}"
)
back.clicked.connect(self.reject)
left_layout.addWidget(back)
@@ -979,7 +983,7 @@ class DiagnosisDialog(QDialog):
self.readonly_hero_name.setObjectName("DiagnosisReadonlyPatientName")
right_layout.addWidget(self.readonly_hero_name)
self.readonly_hero_meta = QLabel("")
self.readonly_hero_meta.setStyleSheet("color:#64748B; font-size:13px;")
self.readonly_hero_meta.setStyleSheet("color:#9AA7C0; font-size:13px;")
right_layout.addWidget(self.readonly_hero_meta)
self.readonly_status = QLabel("从未打卡")
self.readonly_status.setObjectName("DiagnosisReadonlyStatus")
@@ -1115,11 +1119,6 @@ class DiagnosisDialog(QDialog):
def _build_drawer_footer(self) -> QFrame:
footer = QFrame()
footer.setObjectName("DiagnosisDrawerFooter")
shadow = QGraphicsDropShadowEffect(footer)
shadow.setBlurRadius(18)
shadow.setOffset(0, -4)
shadow.setColor(QColor(15, 23, 42, 42))
footer.setGraphicsEffect(shadow)
layout = QHBoxLayout(footer)
layout.setContentsMargins(20, 14, 20, 18)
layout.setSpacing(12)
@@ -1161,42 +1160,58 @@ class DiagnosisDialog(QDialog):
scroll.setObjectName("DiagnosisDrawerBody")
scroll.setProperty("diagnosisTabKey", "basic")
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll.verticalScrollBar().setSingleStep(28)
scroll.verticalScrollBar().setPageStep(120)
content = QWidget()
content.setObjectName("DiagnosisTabBasic")
content.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
content.setMinimumWidth(0)
content.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
layout = QVBoxLayout(content)
layout.setContentsMargins(16, 14, 16, 18)
layout.setSpacing(12)
layout.setContentsMargins(20, 16, 20, 20)
layout.setSpacing(8)
self.privacy_banner = QFrame()
self.privacy_banner.setObjectName("DiagnosisPrivacyBanner")
privacy_layout = QHBoxLayout(self.privacy_banner)
privacy_layout.setContentsMargins(12, 9, 12, 9)
self.privacy_label = QLabel("存在未完成的业务订单,患者基本信息不可修改")
self.privacy_label.setWordWrap(True)
self.privacy_label.setStyleSheet("color:#92400E; font-size:12px;")
self.privacy_label.setStyleSheet("color:#E4B967; font-size:12px;")
privacy_layout.addWidget(self.privacy_label)
self.privacy_banner.hide()
layout.addWidget(self.privacy_banner)
self.form_host = QFrame()
self.form_host.setObjectName("DiagnosisFormHost")
self.form_host.setProperty("locked", False)
self.form_host.setMinimumWidth(0)
form_layout = QVBoxLayout(self.form_host)
form_layout.setContentsMargins(0, 4, 0, 16)
form_layout.setSpacing(0)
form_layout.setContentsMargins(0, 0, 0, 8)
form_layout.setSpacing(12)
self.edit_fields: dict[str, Any] = {}
self.display_fields: dict[str, Any] = {}
for section_key, (title, rows) in enumerate(_FORM_SECTIONS):
form_layout.addWidget(section_heading(title, f"DiagnosisSection_{section_key}"))
for row_index, row_fields in enumerate(rows):
row_host = QWidget()
row_host.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
)
row_layout = QBoxLayout(QBoxLayout.Direction.LeftToRight, row_host)
row_layout.setContentsMargins(0, 0, 0, 8 if row_index == len(rows) - 1 else 18)
row_layout.setSpacing(12)
row_layout.setContentsMargins(0, 0, 0, 6 if row_index == len(rows) - 1 else 10)
row_layout.setSpacing(16)
fields: list[QWidget] = []
occupied = 0
for label, key, span, field_kind in row_fields:
editor = self._ensure_editor(key, field_kind)
container = self._field_container(label, editor, span)
display = (
editor.wrap_with_unit()
if isinstance(editor, DiagnosisNumberEdit)
else editor
)
container = self._field_container(label, display, span, editor=editor)
row_layout.addWidget(container, span)
fields.append(container)
occupied += span
@@ -1214,22 +1229,56 @@ class DiagnosisDialog(QDialog):
scroll.setWidget(content)
return scroll
def _field_container(self, caption: str, editor: QWidget, span: int) -> QWidget:
def _field_container(
self,
caption: str,
display: QWidget,
span: int,
*,
editor: QWidget | None = None,
) -> QWidget:
# `display` may wrap a number edit with an external unit label; stacked
# detection must use the semantic editor when provided.
semantic = editor if editor is not None else display
container = QWidget()
container.setMinimumWidth(0)
container.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
container.setProperty("diagnosisFieldSpan", span)
policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
policy.setHorizontalStretch(max(1, span))
# Choice/flow editors need height-for-width; propagate so the form row grows.
if hasattr(semantic, "hasHeightForWidth") and semantic.hasHeightForWidth():
policy.setHeightForWidth(True)
container.setSizePolicy(policy)
# Multi-option chips sit under the label so wrapped rows stay visible.
stacked = isinstance(semantic, DiagnosisChoiceButtons) or (
hasattr(semantic, "hasHeightForWidth") and semantic.hasHeightForWidth()
)
layout = QBoxLayout(
QBoxLayout.Direction.TopToBottom if stacked else QBoxLayout.Direction.LeftToRight,
container,
)
layout.setContentsMargins(0, 2, 0, 2)
layout.setSpacing(6 if stacked else 8)
label = QLabel(caption)
label.setObjectName("DiagnosisFieldLabel")
label.setProperty("diagnosisFieldLabel", True)
label.setFixedWidth(62 if span <= 8 else 88 if span <= 12 else 120)
label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignTop)
label.setContentsMargins(0, 8, 0, 0)
layout.addWidget(label)
editor.setMinimumWidth(0)
editor.setSizePolicy(QSizePolicy.Policy.Ignored, editor.sizePolicy().verticalPolicy())
layout.addWidget(editor, 1)
if stacked:
label.setMinimumWidth(0)
label.setMaximumWidth(16777215)
label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
else:
label.setFixedWidth(100)
label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
label.setWordWrap(False)
layout.addWidget(label, 0, Qt.AlignmentFlag.AlignTop)
display.setMinimumWidth(0)
editor_policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
if hasattr(semantic, "hasHeightForWidth") and semantic.hasHeightForWidth():
editor_policy.setHeightForWidth(True)
if not isinstance(semantic, DiagnosisSwitch):
display.setSizePolicy(editor_policy)
layout.addWidget(display, 1)
container.setProperty("diagnosisFieldStacked", stacked)
return container
def _ensure_editor(self, key: str, field_kind: str) -> QWidget:
@@ -1239,7 +1288,7 @@ class DiagnosisDialog(QDialog):
return self.display_fields[key]
if field_kind == "gender":
editor: Any = DiagnosisChoiceButtons(
(("", 1), ("", 0)), radio_buttons=True, columns=2
(("", 1), ("", 0)), multiple=False, columns=2
)
elif field_kind == "marital":
editor = DiagnosisComboBox((("未婚", 0), ("已婚", 1), ("离异", 2)))
@@ -1250,7 +1299,7 @@ class DiagnosisDialog(QDialog):
)
elif field_kind == "status":
editor = DiagnosisChoiceButtons(
(("启用", 1), ("禁用", 0)), radio_buttons=True, columns=2
(("启用", 1), ("禁用", 0)), multiple=False, columns=2
)
elif field_kind == "show_card":
editor = DiagnosisSwitch()
@@ -1266,13 +1315,13 @@ class DiagnosisDialog(QDialog):
elif field_kind == "age_number":
editor = DiagnosisNumberEdit(maximum=150)
elif field_kind == "height_number":
editor = DiagnosisNumberEdit(maximum=300, decimals=1, step=0.5, suffix=" cm")
editor = DiagnosisNumberEdit(maximum=300, decimals=1, step=0.5, suffix="cm")
elif field_kind == "weight_number":
editor = DiagnosisNumberEdit(maximum=500, decimals=1, step=0.1, suffix=" kg")
editor = DiagnosisNumberEdit(maximum=500, decimals=1, step=0.1, suffix="kg")
elif field_kind == "systolic_number":
editor = DiagnosisNumberEdit(maximum=250, suffix=" mmHg")
editor = DiagnosisNumberEdit(maximum=250, suffix="mmHg")
elif field_kind == "diastolic_number":
editor = DiagnosisNumberEdit(maximum=150, suffix=" mmHg")
editor = DiagnosisNumberEdit(maximum=150, suffix="mmHg")
elif field_kind == "date":
editor = DiagnosisDateEdit()
elif field_kind == "local_diagnosis":
@@ -1283,10 +1332,16 @@ class DiagnosisDialog(QDialog):
)
self._choice_fields[key] = editor
elif field_kind == "boolean":
editor = DiagnosisChoiceButtons((("", 1), ("", 0)), columns=2)
editor = DiagnosisChoiceButtons(
(("", 1), ("", 0)), multiple=False, columns=2
)
elif field_kind.endswith("_choices"):
multiple = _CHOICE_DICTIONARIES.get(key, (key, True))[1]
editor = DiagnosisChoiceButtons((), multiple=multiple, columns=4)
editor = DiagnosisChoiceButtons(
(),
multiple=multiple,
columns=4 if multiple else 5,
)
self._choice_fields[key] = editor
elif field_kind.startswith("textarea"):
rows = int(field_kind[-1])
@@ -1383,7 +1438,7 @@ class DiagnosisDialog(QDialog):
toolbar_layout.setContentsMargins(14, 10, 14, 10)
label = QLabel("复诊统计起始偏移")
label.setObjectName("DiagnosisOrderOffsetLabel")
label.setStyleSheet("color:#606266; font-size:12px; font-weight:500;")
label.setStyleSheet("color:#9AA7C0; font-size:12px; font-weight:500;")
label.setToolTip(_ORDER_OFFSET_HELP)
toolbar_layout.addWidget(label)
help_button = QPushButton("?")
@@ -1393,10 +1448,10 @@ class DiagnosisDialog(QDialog):
help_button.setFixedSize(22, 22)
help_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
help_button.setStyleSheet(
"QPushButton{color:#909399;border:1px solid #D9DEE5;border-radius:11px;"
"background:#FFFFFF;padding:0;font-size:12px;font-weight:600;}"
"QPushButton:hover,QPushButton:focus{color:#409EFF;border-color:#409EFF;"
"background:#ECF5FF;}"
"QPushButton{color:#9AA7C0;border:1px solid #29334F;border-radius:11px;"
"background:#151D31;padding:0;font-size:12px;font-weight:600;}"
"QPushButton:hover,QPushButton:focus{color:#EEF2FF;border-color:#6675F5;"
"background:#1B2440;}"
)
toolbar_layout.addWidget(help_button)
self.order_offset_help = help_button
@@ -1411,7 +1466,7 @@ class DiagnosisDialog(QDialog):
self.order_offset_preview = QLabel("第 1 笔实单计为一诊")
self.order_offset_preview.setObjectName("DiagnosisOrderOffsetPreview")
self.order_offset_preview.setToolTip(_ORDER_OFFSET_HELP)
self.order_offset_preview.setStyleSheet("color:#909399; font-size:12px;")
self.order_offset_preview.setStyleSheet("color:#9AA7C0; font-size:12px;")
toolbar_layout.addWidget(self.order_offset_preview)
offset_save = QPushButton("保存")
offset_save.setProperty("variant", "primary")
@@ -1424,7 +1479,7 @@ class DiagnosisDialog(QDialog):
layout.addWidget(self.orders_table, 1)
footer = QHBoxLayout()
self.orders_summary = QLabel("共 0 条")
self.orders_summary.setStyleSheet("color:#999999; font-size:12px;")
self.orders_summary.setStyleSheet("color:#9AA7C0; font-size:12px;")
footer.addWidget(self.orders_summary)
footer.addStretch(1)
self.orders_previous = QPushButton("上一页")
@@ -1649,19 +1704,49 @@ class DiagnosisDialog(QDialog):
overlay.sync_geometry()
def _reflow_form(self, viewport_width: int) -> None:
"""Mirror the admin breakpoint: desktop grid survives a 1024px viewport."""
"""Adapt the diagnosis form to the live drawer content width.
narrow = viewport_width <= 768
Wide/medium drawers keep a stable two-column grid. Only phone-narrow
drawers stack fields and flip labels above editors.
"""
content_w = max(240, int(viewport_width) - 56)
mode = "grid" if content_w >= 520 else "stack"
narrow = mode == "stack"
if narrow == self._form_narrow:
return
self._form_narrow = narrow
for row_layout, _fields, remainder in self._form_rows:
for row_layout, fields, remainder in self._form_rows:
row_layout.setDirection(
QBoxLayout.Direction.TopToBottom if narrow else QBoxLayout.Direction.LeftToRight
)
row_layout.setSpacing(18 if narrow else 12)
row_layout.setSpacing(12 if narrow else 16)
if remainder is not None:
remainder.setVisible(not narrow)
for container in fields:
self._orient_field_container(container, stacked=narrow)
def _orient_field_container(self, container: QWidget, *, stacked: bool) -> None:
# Choice fields stay label-above permanently so wrapped chips never clip.
if bool(container.property("diagnosisFieldStacked")):
return
layout = container.layout()
if not isinstance(layout, QBoxLayout):
return
label = container.findChild(QLabel, "DiagnosisFieldLabel")
layout.setDirection(
QBoxLayout.Direction.TopToBottom if stacked else QBoxLayout.Direction.LeftToRight
)
layout.setSpacing(6 if stacked else 8)
if label is None:
return
if stacked:
label.setMinimumWidth(0)
label.setMaximumWidth(16777215)
label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
else:
label.setFixedWidth(100)
label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
def _sync_host_geometry(self) -> None:
owner = self._owner
@@ -1679,7 +1764,9 @@ class DiagnosisDialog(QDialog):
return
drawer_width = self.width() if self.width() <= 768 else max(456, round(self.width() * 0.60))
self.drawer_panel.setFixedWidth(drawer_width)
self._reflow_form(self.width())
# Reflow against the actual drawer content width, not the fullscreen overlay.
self._reflow_form(drawer_width)
self._sync_choice_field_heights()
def _reflow_readonly_hero(self) -> None:
if not hasattr(self, "readonly_hero_layout"):
@@ -2000,6 +2087,16 @@ class DiagnosisDialog(QDialog):
choices.insert(0, ("", ""))
if editor is not None:
editor.set_choices(choices, preserve=False)
# Defer until the drawer has a real width so wrapped chips get height.
QTimer.singleShot(0, self._sync_choice_field_heights)
def _sync_choice_field_heights(self) -> None:
for editor in self._choice_fields.values():
if hasattr(editor, "_sync_flow_height"):
editor._sync_flow_height()
if hasattr(self, "form_host"):
self.form_host.updateGeometry()
self.form_host.adjustSize()
def _show_authoritative_content(self, visible: bool) -> None:
self.readonly_patient_card.setVisible(visible)
@@ -2265,6 +2362,12 @@ class DiagnosisDialog(QDialog):
raw = first_value(diagnosis, "oral_condition", "mouth_condition", default="")
if key == "diagnosis_type":
raw = _diagnosis_type_value(raw)
if key == "gender":
gender_token = str(raw).strip().lower() if raw not in (None, "") else ""
if gender_token in {"2", "f", "female", ""}:
raw = 0
elif gender_token in {"1", "m", "male", ""}:
raw = 1
self._field_originals[key] = raw
if key == "phone" and not self._can_phone_plain:
rendered = _mask_phone(raw)
@@ -2880,14 +2983,22 @@ class DiagnosisDialog(QDialog):
if not self._editable or not self._can_prescribe:
self._show_message("当前账号无开方权限或接口不可用。", "warning")
return
from .prescription import PrescriptionEditorDialog
from .prescription import (
PrescriptionEditorDialog,
build_prescription_clinical_diagnosis,
build_prescription_visit_no,
)
detail = self._detail or {}
diagnosis = get_value(detail, "diagnosis", None) or detail
appointment = get_value(detail, "appointment", None) or {}
case_record = first_value(diagnosis, "case_record", default={}) or {}
diagnosis_id = self._diagnosis_id
appointment_id = _int(first_value(appointment, "id", "appointment_id"), 0)
appointment_id = _int(
first_value(appointment, "id", "appointment_id", default=0)
or first_value(diagnosis, "appointment_id", default=0),
0,
)
seed = {
"diagnosis_id": diagnosis_id,
"appointment_id": appointment_id,
@@ -2895,12 +3006,13 @@ class DiagnosisDialog(QDialog):
"patient_name": first_value(diagnosis, "patient_name", "name"),
"gender": _int(first_value(diagnosis, "gender"), 0),
"age": _int(first_value(diagnosis, "age"), 0),
"visit_no": f"1K{appointment_id:08d}" if appointment_id > 0 else "",
"phone": first_value(diagnosis, "phone", default=""),
"visit_no": build_prescription_visit_no(
diagnosis_id=diagnosis_id, appointment_id=appointment_id
),
"tongue": first_value(diagnosis, "tongue", "tongue_coating"),
"pulse": first_value(diagnosis, "pulse"),
"clinical_diagnosis": first_value(
diagnosis, "clinical_diagnosis", "diagnosis", default=""
),
"clinical_diagnosis": build_prescription_clinical_diagnosis(diagnosis, case_record),
"doctor_name": first_value(
appointment, "doctor_name", default=first_value(diagnosis, "doctor_name")
),
@@ -3497,8 +3609,15 @@ class DiagnosisDialog(QDialog):
dialog.finished.connect(clear_dialog)
dialog.open()
def _build_order_detail_dialog(self, detail: Any, order_id: int) -> QDialog:
dialog = OrderDetailDrawer(self)
def _build_order_detail_dialog(
self,
detail: Any,
order_id: int,
*,
host: QWidget | None = None,
) -> QDialog:
owner = host if isinstance(host, QWidget) else self if isinstance(self, QWidget) else None
dialog = OrderDetailDrawer(owner)
header = QFrame()
header.setObjectName("DiagnosisOrderDetailHeader")
header_layout = QHBoxLayout(header)
@@ -4158,9 +4277,19 @@ class DiagnosisDialog(QDialog):
for key, field in self.edit_fields.items()
if key not in {"phone", "id_card"} or self._can_phone_plain
}
for key in ("patient_name", "phone", "id_card", "diagnosis_type"):
self.edit_fields[key].setProperty("invalid", False)
repolish(self.edit_fields[key])
for key in (
"patient_name",
"phone",
"id_card",
"gender",
"age",
"fasting_blood_sugar",
"diagnosis_type",
"local_hospital_name",
):
if key in self.edit_fields:
self.edit_fields[key].setProperty("invalid", False)
repolish(self.edit_fields[key])
patient_name = str(changes.get("patient_name") or "").strip()
if not patient_name:
self._show_message("请输入患者姓名。", "warning")
@@ -4182,10 +4311,37 @@ class DiagnosisDialog(QDialog):
repolish(self.edit_fields["id_card"])
self.edit_fields["id_card"].setFocus()
return
gender_text = str(self.edit_fields["gender"].toPlainText() or "").strip()
if gender_text == "":
self._show_message("请选择性别。", "warning")
self.edit_fields["gender"].setProperty("invalid", True)
repolish(self.edit_fields["gender"])
return
age_text = str(self.edit_fields["age"].toPlainText() or "").strip()
if age_text == "":
self._show_message("请输入年龄。", "warning")
self.edit_fields["age"].setProperty("invalid", True)
repolish(self.edit_fields["age"])
self.edit_fields["age"].setFocus()
return
fasting = str(changes.get("fasting_blood_sugar") or "").strip()
if not fasting:
self._show_message("请输入空腹血糖。", "warning")
self.edit_fields["fasting_blood_sugar"].setProperty("invalid", True)
repolish(self.edit_fields["fasting_blood_sugar"])
self.edit_fields["fasting_blood_sugar"].setFocus()
return
hospital = str(changes.get("local_hospital_name") or "").strip()
if not hospital:
self._show_message("请输入当地就诊医院名称。", "warning")
self.edit_fields["local_hospital_name"].setProperty("invalid", True)
repolish(self.edit_fields["local_hospital_name"])
self.edit_fields["local_hospital_name"].setFocus()
return
diagnosis_type_raw = str(changes.get("diagnosis_type") or "").strip()
diagnosis_type = _diagnosis_type_value(diagnosis_type_raw)
if diagnosis_type_raw and not diagnosis_type:
self._show_message("诊断类型参数无效,请重新选择", "warning")
if not diagnosis_type:
self._show_message("请选择诊断类型。", "warning")
self.edit_fields["diagnosis_type"].setProperty("invalid", True)
repolish(self.edit_fields["diagnosis_type"])
self.edit_fields["diagnosis_type"].setFocus()
@@ -4262,4 +4418,72 @@ class DiagnosisDialog(QDialog):
self._sync_save_button()
__all__ = ["DiagnosisDialog"]
__all__ = ["DiagnosisDialog", "OrderDetailDrawer", "present_order_detail"]
def _unwrap_order_detail(detail: Any) -> Any:
payload = detail
if isinstance(detail, Mapping):
for key in ("data", "detail"):
nested = detail.get(key)
if isinstance(nested, Mapping) and any(
field in nested for field in ("id", "order_no", "prescription_id")
):
payload = nested
break
return payload
def present_order_detail(
parent: QWidget | None,
detail: Any,
*,
order_id: int = 0,
permissions: Any = None,
exec_: bool = True,
) -> OrderDetailDrawer:
"""Open the shared prescription-order detail drawer (admin drawer parity).
Used by diagnosis业务订单处方查看关联订单and 我的患者订单管理.
"""
from types import MethodType
payload = _unwrap_order_detail(detail)
resolved_id = int(order_id or 0)
if resolved_id <= 0:
raw_id = first_value(payload, "id", "order_id", default=0)
try:
resolved_id = int(raw_id or 0)
except (TypeError, ValueError):
resolved_id = 0
class _OrderDetailShim:
pass
shim = _OrderDetailShim()
shim.permissions = permissions
shim._can_order_logs = has_permission(permissions, _ORDER_LOGS_PERMISSION, default=False)
for name in (
"_build_order_detail_dialog",
"_new_order_section",
"_add_order_empty",
"_add_order_field_grid",
"_add_order_amount_overview",
"_add_order_prescription_section",
"_build_order_payment_table",
"_add_order_payment_section",
"_add_order_fulfillment_section",
"_add_order_timeline_item",
"_add_order_logistics_section",
"_add_order_logs_section",
):
setattr(shim, name, MethodType(getattr(DiagnosisDialog, name), shim))
shim._status_kind = DiagnosisDialog._status_kind
dialog = shim._build_order_detail_dialog(payload, resolved_id, host=parent)
if exec_:
dialog.exec()
else:
dialog.open()
return dialog
File diff suppressed because it is too large Load Diff
+219 -23
View File
@@ -4,16 +4,22 @@ from __future__ import annotations
from typing import Any
from PySide6.QtCore import QSettings, Qt, Signal
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,
@@ -22,6 +28,42 @@ from PySide6.QtWidgets import (
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.
@@ -58,12 +100,86 @@ class LoginWindow(QMainWindow):
self.authenticated_user: Any = None
self._loading = False
self.setWindowTitle("臻阳堂 · 医生工作站")
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)
@@ -87,18 +203,21 @@ class LoginWindow(QMainWindow):
mark.setAlignment(Qt.AlignmentFlag.AlignCenter)
mark.setFixedSize(42, 42)
mark.setStyleSheet(
"color:#0F6D64; background:#DDF1EC; border-radius:12px; font-size:20px; font-weight:700;"
"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:#FCFBF8; font-size:16px; font-weight:700;")
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("DOCTOR WORKSTATION")
eyebrow.setStyleSheet("color:#82B7A9; font-size:11px; font-weight:700; letter-spacing:1px;")
eyebrow.setStyleSheet(
"color:#4F63D9; font-size:11px; font-weight:700; letter-spacing:1px;"
)
layout.addWidget(eyebrow)
headline = QLabel("把诊间工作,\n留在一个安静的界面里。")
headline.setProperty("role", "display")
@@ -106,27 +225,39 @@ class LoginWindow(QMainWindow):
layout.addWidget(headline)
description = QLabel("接诊、问诊、患者与处方信息统一呈现,帮助医生专注于每一次沟通。")
description.setWordWrap(True)
description.setStyleSheet("color:#B8CCC5; font-size:14px; line-height:1.6;")
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:#82A198; font-size:11px;")
privacy.setStyleSheet("color:#667085; font-size:11px;")
layout.addWidget(privacy)
return panel
def _build_login_area(self) -> QWidget:
area = QWidget()
outer = QVBoxLayout(area)
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.setMaximumWidth(470)
self.card.setMinimumWidth(400)
self.card.setMaximumWidth(520)
self.card.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
card_layout = QVBoxLayout(self.card)
card_layout.setContentsMargins(40, 36, 40, 36)
card_layout.setSizeConstraint(QLayout.SizeConstraint.SetMinimumSize)
card_layout.setContentsMargins(34, 30, 34, 30)
card_layout.setSpacing(14)
title = QLabel("欢迎回来")
@@ -169,11 +300,11 @@ class LoginWindow(QMainWindow):
card_layout.addLayout(password_row)
choices = QHBoxLayout()
self.remember_check = QCheckBox("记住账号")
self.remember_check = _VisibleCheckBox("记住账号")
self.remember_check.setToolTip("仅保存账号,不保存密码")
choices.addWidget(self.remember_check)
choices.addStretch(1)
self.demo_check = QCheckBox("演示模式")
self.demo_check = _VisibleCheckBox("演示模式")
self.demo_check.setEnabled(self.demo_repository is not None)
if self.demo_repository is None:
self.demo_check.setToolTip("当前未配置演示数据")
@@ -195,30 +326,58 @@ class LoginWindow(QMainWindow):
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)
server_layout.addWidget(QLabel("服务地址"))
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.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.addWidget(QLabel("读取超时"))
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)
server_hint = QLabel("生产环境应使用管理员下发的 HTTPS 地址。")
server_hint.setProperty("role", "muted")
server_hint.setWordWrap(True)
server_layout.addWidget(server_hint)
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)
@@ -251,6 +410,12 @@ class LoginWindow(QMainWindow):
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)
if remembered:
@@ -280,8 +445,18 @@ class LoginWindow(QMainWindow):
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")
@@ -289,22 +464,33 @@ class LoginWindow(QMainWindow):
self.error_banner.show_message(
"服务地址需使用 HTTPS;本机调试可使用 localhost。", "warning"
)
return
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"],
)
self.error_banner.show_message("服务器设置已保存,将在连接时生效。", "success")
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)
@@ -338,6 +524,8 @@ class LoginWindow(QMainWindow):
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")
@@ -392,6 +580,7 @@ class LoginWindow(QMainWindow):
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)
@@ -430,6 +619,13 @@ class LoginWindow(QMainWindow):
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)
@@ -1,5 +1,6 @@
"""Business pages shown inside :class:`doctor_workstation.ui.shell.ShellWindow`."""
from .appointments import AppointmentsPage
from .consultations import ConsultationsPage
from .patients import PatientsPage
from .prescription_library import PrescriptionLibraryPage
@@ -7,6 +8,7 @@ from .prescriptions import PrescriptionsPage
from .reception import ReceptionPage
__all__ = [
"AppointmentsPage",
"ConsultationsPage",
"PatientsPage",
"PrescriptionLibraryPage",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+218 -11
View File
@@ -8,7 +8,7 @@ from types import MappingProxyType
from typing import Any
from PySide6.QtCore import QDate, Qt, QTime, QTimer, Signal
from PySide6.QtGui import QAction
from PySide6.QtGui import QAction, QBrush, QColor
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
@@ -37,7 +37,8 @@ from PySide6.QtWidgets import (
)
from ..appointment_drawer import AppointmentDrawer
from ..dialogs import DiagnosisDialog
from ..dialogs import DiagnosisDialog, present_order_detail
from ..dialogs.prescription import PrescriptionOrderListDialog
from ..widgets import (
EmptyState,
MessageBanner,
@@ -86,6 +87,73 @@ FULFILLMENT_TEXT = {
12: "制药缓发",
}
_SEMANTIC_COLORS = {
"primary": "#4F63D9",
"success": "#16876C",
"warning": "#9A6813",
"danger": "#C43E55",
"info": "#2F6EDB",
"muted": "#667085",
}
PATIENTS_LIGHT_QSS = """
#PatientsPage QPushButton[variant="ghost"]:checked {
color: #3446AF;
background-color: #E9EDFF;
border: 1px solid #4F63D9;
}
#PatientsPage QPushButton[variant="ghost"]:checked:hover { background-color: #DCE3FF; }
#PatientsPage QPushButton[statusKind="info"]:checked {
color: #2F6EDB;
border-color: #2F6EDB;
}
#PatientsPage QPushButton[statusKind="warning"]:checked {
color: #9A6813;
border-color: #9A6813;
}
#PatientsPage QPushButton[statusKind="success"]:checked {
color: #16876C;
border-color: #16876C;
}
#PatientsPage QPushButton[statusKind="danger"]:checked {
color: #C43E55;
border-color: #C43E55;
}
#PatientsPage QLabel[metricKind="primary"] { color: #4F63D9; }
#PatientsPage QLabel[metricKind="info"] { color: #2F6EDB; }
#PatientsPage QLabel[metricKind="success"] { color: #16876C; }
#PatientsPage QLabel[metricKind="warning"] { color: #9A6813; }
#PatientsPage QLabel[metricKind="danger"] { color: #C43E55; }
#PatientsPage QTabWidget#PatientWorkspaceTabs::pane {
background-color: transparent;
border: 1px solid #D8DEEA;
border-radius: 10px;
top: -1px;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab {
color: #667085;
background-color: transparent;
border: 0;
border-bottom: 2px solid transparent;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab:hover {
color: #172033;
background-color: #F7F8FC;
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab:selected {
color: #3446AF;
background-color: #F7F8FC;
border-bottom-color: #4F63D9;
}
"""
def _style_table_cell(table: SortableTable, row: int, column: int, kind: str) -> None:
item = table.item(row, column)
color = _SEMANTIC_COLORS.get(kind, _SEMANTIC_COLORS["muted"])
if item is not None:
item.setForeground(QBrush(QColor(color)))
def _as_int(value: Any, default: int = 0) -> int:
try:
@@ -1005,6 +1073,7 @@ class PatientListWorkspace(QWidget):
assign_requested = Signal(object)
fill_id_requested = Signal(object)
cancel_requested = Signal(object)
orders_requested = Signal(object)
scope_changed = Signal(str)
def __init__(self, repository: Any, permissions: Any, parent: QWidget | None = None) -> None:
@@ -1049,7 +1118,42 @@ class PatientListWorkspace(QWidget):
self.status_combo.addItem("待面诊", "pending_interview")
self.status_combo.addItem("已完成", "completed")
self.status_combo.addItem("已过号", "missed")
grid.addWidget(self.status_combo, 0, 3)
self.status_combo.hide()
status_host = QWidget()
status_row = QHBoxLayout(status_host)
status_row.setContentsMargins(0, 0, 0, 0)
status_row.setSpacing(6)
self.status_group = QButtonGroup(self)
self.status_group.setExclusive(True)
self.status_buttons: dict[str, QPushButton] = {}
for value, label in (
("", "全部"),
("unbooked", "未预约"),
("pending_interview", "待面诊"),
("completed", "已完成"),
("missed", "已过号"),
):
button = QPushButton(label)
button.setCheckable(True)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setProperty("variant", "ghost")
button.setProperty(
"statusKind",
{
"unbooked": "info",
"pending_interview": "warning",
"completed": "success",
"missed": "danger",
}.get(value, "primary"),
)
button.clicked.connect(
lambda _checked=False, selected=value: self._set_status_filter(selected)
)
self.status_group.addButton(button)
self.status_buttons[value] = button
status_row.addWidget(button)
self.status_buttons[""].setChecked(True)
grid.addWidget(status_host, 0, 3)
search = QPushButton("查询")
search.setProperty("variant", "secondary")
search.clicked.connect(self.search)
@@ -1205,6 +1309,9 @@ class PatientListWorkspace(QWidget):
self.fill_id_button = QPushButton("补全身份证")
self.fill_id_button.clicked.connect(lambda: self._emit_selected(self.fill_id_requested))
layout.addWidget(self.fill_id_button)
self.orders_button = QPushButton("关联订单")
self.orders_button.clicked.connect(lambda: self._emit_selected(self.orders_requested))
layout.addWidget(self.orders_button)
self.cancel_button = QPushButton("取消挂号")
self.cancel_button.setProperty("variant", "danger")
self.cancel_button.clicked.connect(lambda: self._emit_selected(self.cancel_requested))
@@ -1247,6 +1354,9 @@ class PatientListWorkspace(QWidget):
self.fill_id_button.setVisible(
editable and selected and not _as_bool(first_value(row, "has_id_card", default=False))
)
can_orders = _canonical_allowed(self.permissions, "tcm.prescriptionOrder/lists")
self.orders_button.setVisible(can_orders)
self.orders_button.setEnabled(selected)
status = _as_int(first_value(row, "appointment_status"), -1)
appointment_id = _as_int(first_value(row, "appointment_id"))
self.cancel_button.setVisible(
@@ -1286,6 +1396,13 @@ class PatientListWorkspace(QWidget):
if refresh:
self.search()
def _set_status_filter(self, value: str) -> None:
index = self.status_combo.findData(value)
self.status_combo.setCurrentIndex(max(0, index))
for key, button in self.status_buttons.items():
button.setChecked(key == value)
self.search()
def _custom_date_changed(self) -> None:
if self._setting_dates:
return
@@ -1297,6 +1414,8 @@ class PatientListWorkspace(QWidget):
def reset_filters(self) -> None:
self.keyword_edit.clear()
self.status_combo.setCurrentIndex(0)
for key, button in self.status_buttons.items():
button.setChecked(key == "")
self.set_date_mode("all", refresh=False)
self.search()
@@ -1343,6 +1462,12 @@ class PatientListWorkspace(QWidget):
return
rows = page_items(result)
self.table.set_rows(rows)
for row_index in range(self.table.rowCount()):
source_item = self.table.item(row_index, 0)
source = (
source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None
)
_style_table_cell(self.table, row_index, 2, _patient_status(source)[1])
self.pager.update_state(self._page, page_total(result, len(rows)))
self.content_stack.setCurrentIndex(0 if rows else 1)
extend = _page_extend(result)
@@ -1455,6 +1580,14 @@ class PatientOrdersWorkspace(QWidget):
layout = QHBoxLayout()
layout.setSpacing(8)
self.metrics: dict[str, QLabel] = {}
metric_kinds = {
"orders": "info",
"amount": "success",
"pending": "warning",
"completed": "success",
"rejected": "danger",
"rejection_rate": "danger",
}
for key, label in (
("orders", "订单数量"),
("amount", "有效金额"),
@@ -1471,7 +1604,8 @@ class PatientOrdersWorkspace(QWidget):
caption = QLabel(label)
caption.setProperty("role", "muted")
value = QLabel("0")
value.setStyleSheet("font-size:16px; font-weight:700; color:#17382F;")
value.setProperty("metricKind", metric_kinds[key])
value.setStyleSheet("font-size:16px; font-weight:700;")
box.addWidget(caption)
box.addWidget(value)
layout.addWidget(frame, 1)
@@ -1640,6 +1774,31 @@ class PatientOrdersWorkspace(QWidget):
return
rows = page_items(result)
self.table.set_rows(rows)
for row_index in range(self.table.rowCount()):
source_item = self.table.item(row_index, 0)
source = (
source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None
)
prescription_audit = _as_int(first_value(source, "prescription_audit_status"), -1)
payment_audit = _as_int(first_value(source, "payment_slip_audit_status"), -1)
fulfillment = _as_int(first_value(source, "fulfillment_status"), -1)
audit_kinds = {0: "warning", 1: "success", 2: "danger"}
fulfillment_kind = (
"success"
if fulfillment in {3, 6}
else "danger"
if fulfillment in {4, 9, 10}
else "info"
if fulfillment in {8, 11}
else "warning"
)
_style_table_cell(
self.table, row_index, 4, audit_kinds.get(prescription_audit, "muted")
)
_style_table_cell(
self.table, row_index, 5, audit_kinds.get(payment_audit, "muted")
)
_style_table_cell(self.table, row_index, 6, fulfillment_kind)
self.pager.update_state(self._page, page_total(result, len(rows)))
self.content_stack.setCurrentIndex(0 if rows else 1)
extend = _page_extend(result)
@@ -1834,6 +1993,7 @@ class PatientProgressWorkspace(QWidget):
layout = QHBoxLayout()
layout.setSpacing(8)
self.overview: dict[str, tuple[QLabel, QLabel]] = {}
metric_kinds = {"total": "info", "waiting": "warning", "completed": "success"}
for key, caption in (
("total", "今日面诊总数"),
("waiting", "待面诊"),
@@ -1846,7 +2006,8 @@ class PatientProgressWorkspace(QWidget):
label = QLabel(caption)
label.setProperty("role", "muted")
value = QLabel("0")
value.setStyleSheet("font-size:17px; font-weight:700; color:#17382F;")
value.setProperty("metricKind", metric_kinds[key])
value.setStyleSheet("font-size:17px; font-weight:700;")
hint = QLabel("")
hint.setProperty("role", "muted")
box.addWidget(label)
@@ -2016,6 +2177,19 @@ class PatientProgressWorkspace(QWidget):
rows = page_items(result)
total = page_total(result, len(rows))
self.queue_table.set_rows(rows)
for row_index in range(self.queue_table.rowCount()):
source_item = self.queue_table.item(row_index, 0)
source = (
source_item.data(Qt.ItemDataRole.UserRole) if source_item is not None else None
)
queue_status = str(first_value(source, "queue_status", default="") or "").lower()
status_kind = {
"consulting": "success",
"next": "info",
"waiting": "warning",
"missed": "danger",
}.get(queue_status, "muted")
_style_table_cell(self.queue_table, row_index, 5, status_kind)
self.pager.update_state(self._page, total)
self.queue_count.setText(f"{total} 人 · 每 15 秒刷新")
self.queue_stack.setCurrentIndex(0 if rows else 1)
@@ -2089,6 +2263,8 @@ class PatientsPage(QWidget):
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("PatientsPage")
self.setStyleSheet(PATIENTS_LIGHT_QSS)
self.repository = repository
self.permissions = permissions
self.current_user = current_user
@@ -2127,6 +2303,7 @@ class PatientsPage(QWidget):
self.patient_workspace.assign_requested.connect(self._load_assistants)
self.patient_workspace.fill_id_requested.connect(self._fill_id_card)
self.patient_workspace.cancel_requested.connect(self._cancel_appointment)
self.patient_workspace.orders_requested.connect(self._open_patient_orders)
self.patient_workspace.scope_changed.connect(self._set_scope)
self.order_workspace.diagnosis_requested.connect(self._open_order_diagnosis)
self.order_workspace.detail_requested.connect(self._load_order_detail)
@@ -2158,7 +2335,8 @@ class PatientsPage(QWidget):
refresh()
def _diagnosis_id(self, row: Any) -> int:
return _as_int(first_value(row, "diagnosis_id", "patient_id", "id"))
# Never fall back to patient_id — that id is a different resource.
return _as_int(first_value(row, "diagnosis_id", default=first_value(row, "id", default=0)))
def _open_diagnosis(self, row: Any, editable: bool) -> None:
diagnosis_id = self._diagnosis_id(row)
@@ -2169,7 +2347,10 @@ class PatientsPage(QWidget):
if not _canonical_allowed(self.permissions, canonical):
show_toast(self, "当前账号没有该诊单权限。", "danger")
return
self.diagnosis_dialog.open_for(diagnosis_id, editable=editable, seed=row)
if editable:
self.diagnosis_dialog.open_for(diagnosis_id, editable=True, seed=row)
else:
self.diagnosis_dialog.open_view_only(diagnosis_id, seed=row)
def _open_order_diagnosis(self, row: Any) -> None:
editable = _canonical_allowed(self.permissions, "tcm.diagnosis/edit")
@@ -2355,6 +2536,19 @@ class PatientsPage(QWidget):
success="身份信息已补全。",
)
def _open_patient_orders(self, row: Any) -> None:
if not _canonical_allowed(self.permissions, "tcm.prescriptionOrder/lists"):
return
patient_id = _as_int(first_value(row, "patient_id", "user_id"))
keyword = display_text(first_value(row, "patient_name", "phone"), "")
PrescriptionOrderListDialog(
self.repository,
patient_id=patient_id or None,
keyword=None if patient_id > 0 else (keyword or None),
permissions=self.permissions,
parent=self,
).exec()
def _load_order_detail(self, row: Any) -> None:
if not _canonical_allowed(self.permissions, "tcm.prescriptionOrder/detail"):
return
@@ -2364,23 +2558,36 @@ class PatientsPage(QWidget):
self._detail_generation += 1
generation = self._detail_generation
self.order_workspace.banner.show_message("正在加载订单详情…", "info")
# Prefer prescription-order detail (admin drawer payload); fall back to patient API.
detail_methods = (
("get_prescription_order", "get_patient_order")
if callable(getattr(self.repository, "get_prescription_order", None))
else ("get_patient_order",)
)
run_async(
lambda: _invoke_first(
self.repository,
("get_patient_order",),
detail_methods,
id=order_id,
order_id=order_id,
),
on_success=lambda result: self._show_order_detail(result, generation),
on_success=lambda result: self._show_order_detail(result, generation, order_id),
on_error=lambda error: self._order_detail_error(error, generation),
on_finished=lambda: None,
)
def _show_order_detail(self, detail: Any, generation: int) -> None:
def _show_order_detail(self, detail: Any, generation: int, order_id: int = 0) -> None:
if generation != self._detail_generation:
return
self.order_workspace.banner.clear()
_OrderDetailDialog(detail, self).exec()
host = self.window() if self.window() is not None else self
present_order_detail(
host,
detail,
order_id=order_id or _as_int(first_value(detail, "id", "order_id")),
permissions=self.permissions,
exec_=True,
)
def _order_detail_error(self, error: Exception, generation: int) -> None:
if generation == self._detail_generation:
@@ -695,7 +695,12 @@ class PrescriptionsPage(QWidget):
):
return
self.banner.clear()
DiagnosisDetailDialog(detail, self).exec()
DiagnosisDetailDialog(
detail,
self,
repository=self.repository,
permissions=self.permissions,
).exec()
def _diagnosis_detail_error(self, error: Exception, diagnosis_id: int, generation: int) -> None:
if (
@@ -903,6 +908,7 @@ class PrescriptionsPage(QWidget):
PrescriptionOrderListDialog(
self.repository,
prescription_id=prescription_id,
permissions=self.permissions,
parent=self,
).exec()
@@ -142,7 +142,7 @@ class QueueRow(QWidget):
name = QLabel(
display_text(first_value(record, "patient_name", "name", default="未命名患者"))
)
name.setStyleSheet("font-size:14px; font-weight:700; color:#17382F;")
name.setStyleSheet("font-size:14px; font-weight:700; color:#172033;")
top.addWidget(name)
top.addStretch(1)
status_number = _as_int(first_value(record, "status", default=1), 1) or 1
File diff suppressed because it is too large Load Diff
+641 -166
View File
@@ -1,296 +1,771 @@
"""Application-wide visual theme.
"""Application-wide light desktop theme.
The UI deliberately uses a restrained, clinical palette: warm whites for long
working sessions, ink green navigation, and teal for actionable state. The
theme is pure QSS so it remains dependable in frozen Windows and macOS builds.
The default workstation palette uses white data surfaces, cool neutral canvas
tones and a restrained indigo accent. Dense medical content stays opaque and
high-contrast while borders and focus states preserve the desktop hierarchy.
"""
from __future__ import annotations
from string import Template
from PySide6.QtGui import QColor, QPalette
from PySide6.QtWidgets import QApplication
# Canonical semantic tokens. The legacy teal/ink aliases remain available to
# callers while the stylesheet itself is generated from this mapping.
COLORS = {
"canvas": "#F4F3EF",
"surface": "#FCFBF8",
"surface_alt": "#F0F2EF",
"ink": "#17382F",
"ink_soft": "#315147",
"teal": "#168579",
"teal_dark": "#0F6D64",
"teal_pale": "#DDF1EC",
"text": "#18211E",
"muted": "#66736D",
"line": "#D9DEDA",
"danger": "#B5473F",
"danger_pale": "#F8E8E5",
"warning": "#9A6A19",
"warning_pale": "#F8F0DA",
"success": "#287659",
"success_pale": "#E1F1E9",
"info": "#35698B",
"info_pale": "#E5EFF5",
"canvas": "#F5F7FB",
"canvas_mid": "#F8F9FC",
"canvas_glow": "#EEF2FF",
"surface": "#FFFFFF",
"surface_alt": "#F7F8FC",
"raised": "#EEF2F8",
"glass": "rgba(255, 255, 255, 248)",
"glass_alt": "rgba(247, 248, 252, 250)",
"line": "#D8DEEA",
"line_soft": "rgba(79, 99, 217, 52)",
"text": "#172033",
"text_soft": "#34415A",
"muted": "#667085",
"disabled_surface": "#ECEFF5",
"disabled_text": "#98A2B3",
"indigo": "#4F63D9",
"indigo_hover": "#4053C7",
"indigo_pressed": "#3446AF",
"indigo_pale": "#E9EDFF",
"focus": "#8795F5",
"selection": "#DCE3FF",
"success": "#16876C",
"success_pale": "#E8F6F1",
"warning": "#9A6813",
"warning_pale": "#FFF4D8",
"danger": "#C43E55",
"danger_pale": "#FDECEF",
"info": "#2F6EDB",
"info_pale": "#EAF2FF",
# Backward-compatible names used by older UI code and integrations.
"ink": "#172033",
"ink_soft": "#34415A",
"teal": "#4F63D9",
"teal_dark": "#3446AF",
"teal_pale": "#E9EDFF",
}
GLOBAL_QSS = r"""
GLOBAL_QSS = Template(
r"""
QWidget {
color: #18211E;
color: $text;
background-color: transparent;
font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-size: 13px;
}
QMainWindow, QDialog, QWidget#AppCanvas, QWidget#LoginCanvas {
background-color: #F4F3EF;
QMainWindow, QDialog, QWidget#LoginCanvas {
background-color: $canvas;
}
QWidget#AppCanvas, QWidget#ShellWorkspace, QStackedWidget#ShellPageStack {
background-color: qlineargradient(
x1: 0, y1: 0, x2: 1, y2: 1,
stop: 0 $canvas,
stop: 0.58 $canvas_mid,
stop: 1 $canvas_glow
);
}
QLabel[role="muted"] { color: #66736D; }
QLabel[role="muted"] { color: $muted; }
QLabel[role="danger"] { color: $danger; }
QLabel[role="eyebrow"] {
color: #168579;
color: $indigo_hover;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
}
QLabel[role="pageTitle"] {
color: #17382F;
font-size: 25px;
color: $text;
font-size: 24px;
font-weight: 700;
}
QLabel[role="sectionTitle"] {
color: #17382F;
color: $text;
font-size: 16px;
font-weight: 700;
}
QLabel[role="display"] {
color: #FCFBF8;
color: $text;
font-size: 30px;
font-weight: 700;
}
QLabel[role="metric"] {
color: #17382F;
color: $text;
font-size: 22px;
font-weight: 700;
}
QFrame#Card, QFrame#Panel, QFrame#FilterBar, QFrame#DetailPanel {
background-color: #FCFBF8;
border: 1px solid #D9DEDA;
border-radius: 16px;
QFrame#Card, QFrame#Panel, QFrame#FilterBar, QFrame#DetailPanel,
QFrame[glass="true"] {
background-color: $glass;
border: 1px solid $line_soft;
border-radius: 14px;
}
QFrame#SubtleCard {
background-color: #F0F2EF;
border: 1px solid #E1E5E1;
background-color: $glass_alt;
border: 1px solid $line;
border-radius: 12px;
}
QFrame#Divider { background-color: #D9DEDA; min-height: 1px; max-height: 1px; }
QFrame#Divider {
background-color: $line;
min-height: 1px;
max-height: 1px;
}
QGroupBox {
margin-top: 12px;
padding-top: 12px;
border: 1px solid $line;
border-radius: 12px;
background-color: $glass;
font-weight: 600;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 12px;
padding: 0 6px;
color: $text_soft;
}
QPushButton {
min-height: 36px;
padding: 0 15px;
border: 1px solid #CBD3CE;
border-radius: 9px;
background-color: #FCFBF8;
color: #24443B;
padding: 0 16px;
border: 1px solid $line;
border-radius: 10px;
background-color: $surface_alt;
color: $text;
font-weight: 600;
}
QPushButton:hover { background-color: #F0F2EF; border-color: #AEBBB4; }
QPushButton:pressed { background-color: #E6EAE6; }
QPushButton:disabled { color: #9AA39E; background-color: #EFF1EF; border-color: #E1E5E1; }
QPushButton:hover {
background-color: $raised;
border-color: $indigo_hover;
}
QPushButton:pressed {
background-color: $indigo_pale;
border-color: $indigo_pressed;
}
QPushButton:focus {
border: 2px solid $focus;
}
QPushButton:checked {
color: #FFFFFF;
background-color: $indigo_pressed;
border-color: $indigo_hover;
}
QPushButton:checked:hover { background-color: $indigo; }
QPushButton:disabled {
color: $disabled_text;
background-color: $disabled_surface;
border-color: $line;
}
QPushButton[variant="primary"] {
color: #FFFFFF;
background-color: #168579;
border-color: #168579;
background-color: $indigo;
border-color: $indigo;
}
QPushButton[variant="primary"]:hover { background-color: #0F6D64; border-color: #0F6D64; }
QPushButton[variant="primary"]:hover,
QPushButton[variant="primary"]:checked:hover {
background-color: $indigo_hover;
border-color: $indigo_hover;
}
QPushButton[variant="primary"]:pressed,
QPushButton[variant="primary"]:checked {
background-color: $indigo_pressed;
border-color: $indigo_pressed;
}
QPushButton[variant="primary"]:focus { border: 2px solid $focus; }
QPushButton[variant="primary"]:disabled {
color: $disabled_text;
background-color: $surface_alt;
border-color: $line;
}
QPushButton[variant="secondary"] {
color: #0F6D64;
background-color: #DDF1EC;
border-color: #B7DDD4;
color: $indigo_hover;
background-color: $indigo_pale;
border-color: $line_soft;
}
QPushButton[variant="secondary"]:hover { background-color: #CCE8E1; }
QPushButton[variant="secondary"]:hover {
color: $indigo_pressed;
background-color: $raised;
border-color: $indigo_hover;
}
QPushButton[variant="secondary"]:pressed,
QPushButton[variant="secondary"]:checked {
color: #FFFFFF;
background-color: $indigo_pressed;
border-color: $indigo_pressed;
}
QPushButton[variant="danger"] {
color: #A43C35;
background-color: #F8E8E5;
border-color: #EBC6C1;
color: $danger;
background-color: $danger_pale;
border-color: rgba(240, 120, 134, 112);
}
QPushButton[variant="danger"]:hover {
color: $canvas;
background-color: $danger;
border-color: $danger;
}
QPushButton[variant="danger"]:pressed,
QPushButton[variant="danger"]:checked {
color: #FFFFFF;
background-color: #C85E6C;
border-color: #C85E6C;
}
QPushButton[variant="dangerGhost"] {
color: $danger;
background-color: transparent;
border-color: transparent;
}
QPushButton[variant="dangerGhost"]:hover {
background-color: $danger_pale;
border-color: rgba(240, 120, 134, 96);
}
QPushButton[variant="success"] {
color: #FFFFFF;
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="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="ghost"] {
color: $text_soft;
border-color: transparent;
background-color: transparent;
}
QPushButton[variant="ghost"]:hover {
color: $text;
background-color: $surface_alt;
border-color: $line;
}
QPushButton[variant="ghost"]:pressed,
QPushButton[variant="ghost"]:checked {
color: $indigo_pressed;
background-color: $indigo_pale;
border-color: $indigo_pressed;
}
QPushButton[variant="link"] {
min-height: 28px;
padding: 0 6px;
color: $info;
background-color: transparent;
border-color: transparent;
}
QPushButton[variant="link"]:hover { color: $focus; background-color: $indigo_pale; }
QPushButton[variant="link"]:pressed { color: $indigo_hover; background-color: $surface_alt; }
QPushButton[variant="chip"] {
min-height: 30px;
padding: 0 12px;
color: $text_soft;
background-color: $surface_alt;
border-color: $line;
border-radius: 8px;
}
QPushButton[variant="chip"]:hover {
color: $text;
background-color: $raised;
border-color: $indigo_hover;
}
QPushButton[variant="chip"]:checked {
color: #FFFFFF;
background-color: $indigo_pressed;
border-color: $indigo_hover;
}
QPushButton[variant="danger"]:hover { background-color: #F1D7D3; }
QPushButton[variant="ghost"] { border-color: transparent; background-color: transparent; }
QPushButton[variant="ghost"]:hover { background-color: #E8ECE9; }
QPushButton[variant="nav"] {
min-height: 44px;
padding: 0 15px;
border: 0;
border-radius: 10px;
background-color: transparent;
color: #C8D7D1;
color: $muted;
text-align: left;
font-weight: 600;
}
QPushButton[variant="nav"]:hover { background-color: #244B40; color: #FFFFFF; }
QPushButton[variant="nav"]:checked { background-color: #DDF1EC; color: #0D5E56; }
QPushButton[variant="nav"]:hover { background-color: $surface_alt; color: $text; }
QPushButton[variant="nav"]:pressed { background-color: $indigo_pale; }
QPushButton[variant="nav"]:checked { background-color: $indigo_pressed; color: #FFFFFF; }
QToolButton {
min-width: 32px;
min-height: 32px;
border: 0;
border: 1px solid transparent;
border-radius: 8px;
color: #315147;
color: $text_soft;
background-color: transparent;
}
QToolButton:hover { background-color: #E8ECE9; }
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: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"] {
color: $text_soft;
background-color: $surface_alt;
border: 1px solid $line;
}
QToolButton[diagnosisChip="true"]:hover { color: $text; border-color: $indigo_hover; }
QToolButton[diagnosisChip="true"]:checked {
color: #FFFFFF;
background-color: $indigo_pressed;
border-color: $indigo_hover;
}
QToolButton[diagnosisChip="true"][semantic="info"] { color: $info; background-color: $info_pale; }
QToolButton[diagnosisChip="true"][semantic="success"] { color: $success; background-color: $success_pale; }
QToolButton[diagnosisChip="true"][semantic="warning"] { color: $warning; background-color: $warning_pale; }
QLineEdit, QTextEdit, QPlainTextEdit, QComboBox, QDateEdit, QSpinBox, QDoubleSpinBox {
QLineEdit, QTextEdit, QPlainTextEdit, QComboBox, QDateEdit, QDateTimeEdit,
QTimeEdit, QSpinBox, QDoubleSpinBox, QKeySequenceEdit {
min-height: 36px;
padding: 0 11px;
border: 1px solid #CBD3CE;
border-radius: 9px;
background-color: #FFFFFF;
selection-background-color: #B7DDD4;
selection-color: #17382F;
padding: 0 12px;
border: 1px solid $line;
border-radius: 10px;
background-color: $surface;
color: $text;
selection-background-color: $indigo;
selection-color: #FFFFFF;
}
QTextEdit, QPlainTextEdit { padding: 9px 12px; }
QLineEdit:hover, QTextEdit:hover, QPlainTextEdit:hover, QComboBox:hover,
QDateEdit:hover, QDateTimeEdit:hover, QTimeEdit:hover, QSpinBox:hover,
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;
}
QLineEdit:read-only, QTextEdit:read-only, QPlainTextEdit:read-only {
color: $muted;
background-color: $disabled_surface;
}
QLineEdit:disabled, QTextEdit:disabled, QPlainTextEdit:disabled, QComboBox:disabled,
QDateEdit:disabled, QDateTimeEdit:disabled, QTimeEdit:disabled, QSpinBox:disabled,
QDoubleSpinBox:disabled, QKeySequenceEdit:disabled {
color: $disabled_text;
background-color: $disabled_surface;
border-color: $line;
}
QComboBox::drop-down, QDateEdit::drop-down, QDateTimeEdit::drop-down {
border: 0;
width: 28px;
}
QTextEdit, QPlainTextEdit { padding: 9px 11px; }
QLineEdit:hover, QTextEdit:hover, QPlainTextEdit:hover, QComboBox:hover, QDateEdit:hover,
QSpinBox:hover, QDoubleSpinBox:hover { border-color: #9DAEA5; }
QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus, QComboBox:focus, QDateEdit:focus,
QSpinBox:focus, QDoubleSpinBox:focus { border: 2px solid #168579; }
QLineEdit:disabled, QTextEdit:disabled, QComboBox:disabled { background-color: #EFF1EF; color: #8A958F; }
QComboBox::drop-down, QDateEdit::drop-down { border: 0; width: 25px; }
QComboBox QAbstractItemView {
background-color: #FFFFFF;
border: 1px solid #CBD3CE;
border-radius: 8px;
padding: 4px;
selection-background-color: #DDF1EC;
selection-color: #17382F;
color: $text;
background-color: $raised;
alternate-background-color: $surface_alt;
border: 1px solid $line;
border-radius: 10px;
padding: 5px;
outline: 0;
selection-background-color: $indigo_pressed;
selection-color: #FFFFFF;
}
QCheckBox, QRadioButton { spacing: 8px; }
QCheckBox::indicator, QRadioButton::indicator { width: 17px; height: 17px; }
QCheckBox::indicator:unchecked {
background-color: #FFFFFF;
border: 1px solid #AEBBB4;
border-radius: 4px;
}
QCheckBox::indicator:checked {
background-color: #168579;
border: 1px solid #168579;
border-radius: 4px;
QAbstractSpinBox::up-button, QAbstractSpinBox::down-button {
width: 20px;
border: 0;
background-color: transparent;
}
QTableWidget, QTableView {
background-color: #FCFBF8;
alternate-background-color: #F6F7F4;
QCheckBox, QRadioButton { spacing: 8px; color: $text_soft; }
QCheckBox:hover, QRadioButton:hover { color: $text; }
QCheckBox:disabled, QRadioButton:disabled { color: $disabled_text; }
QCheckBox::indicator, QRadioButton::indicator { width: 17px; height: 17px; }
QCheckBox::indicator:unchecked, QRadioButton::indicator:unchecked {
background-color: $surface;
border: 1px solid $line;
}
QCheckBox::indicator:unchecked { border-radius: 4px; }
QRadioButton::indicator:unchecked { border-radius: 9px; }
QCheckBox::indicator:hover, QRadioButton::indicator:hover { border-color: $indigo_hover; }
QCheckBox::indicator:checked, QRadioButton::indicator:checked {
background-color: $indigo;
border: 1px solid $focus;
}
QCheckBox::indicator:checked { border-radius: 4px; }
QRadioButton::indicator:checked { border-radius: 9px; }
QCheckBox::indicator:disabled, QRadioButton::indicator:disabled {
background-color: $disabled_surface;
border-color: $line;
}
QAbstractItemView, QTableWidget, QTableView, QListWidget, QListView, QTreeWidget, QTreeView {
color: $text;
background-color: $surface;
alternate-background-color: $surface_alt;
border: 0;
border-radius: 12px;
gridline-color: #E5E8E5;
selection-background-color: #DDF1EC;
selection-color: #17382F;
gridline-color: $line;
selection-background-color: $selection;
selection-color: $text;
outline: 0;
}
QTableWidget::item, QTableView::item { padding: 9px 8px; border-bottom: 1px solid #E6E9E6; }
QAbstractItemView:focus { border: 1px solid $indigo_hover; }
QTableWidget::item, QTableView::item {
padding: 9px 8px;
border-bottom: 1px solid $line;
}
QTableWidget::item:hover, QTableView::item:hover { background-color: $surface_alt; }
QTableWidget::item:selected, QTableView::item:selected {
color: $text;
background-color: $selection;
}
QHeaderView::section {
background-color: #EEF1EE;
color: #53635C;
background-color: $surface_alt;
color: $muted;
border: 0;
border-bottom: 1px solid #D9DEDA;
border-right: 1px solid $line;
border-bottom: 1px solid $line;
padding: 10px 8px;
font-size: 12px;
font-weight: 700;
}
QTableCornerButton::section { background-color: #EEF1EE; border: 0; }
QListWidget {
background-color: transparent;
QHeaderView::section:hover { color: $text; background-color: $raised; }
QTableCornerButton::section { background-color: $surface_alt; border: 0; }
QListWidget::item, QListView::item, QTreeWidget::item, QTreeView::item {
border: 0;
outline: 0;
padding: 7px 9px;
margin: 2px 0;
}
QListWidget::item:selected, QListView::item:selected,
QTreeWidget::item:selected, QTreeView::item:selected {
background-color: $indigo_pale;
color: $indigo_pressed;
border-radius: 8px;
}
QListWidget::item:hover, QListView::item:hover,
QTreeWidget::item:hover, QTreeView::item:hover {
background-color: $surface_alt;
border-radius: 8px;
}
QListWidget::item { border: 0; margin: 2px 0; }
QListWidget::item:selected { background-color: #DDF1EC; color: #17382F; border-radius: 11px; }
QListWidget::item:hover { background-color: #F0F2EF; border-radius: 11px; }
QTabWidget::pane {
border: 1px solid $line;
border-radius: 12px;
background-color: $glass;
}
QTabBar::tab {
min-height: 36px;
padding: 0 16px;
margin-right: 4px;
color: #66736D;
color: $muted;
background-color: transparent;
border: 0;
border: 1px solid transparent;
border-radius: 9px;
font-weight: 600;
}
QTabBar::tab:hover { background-color: #F0F2EF; }
QTabBar::tab:selected { background-color: #DDF1EC; color: #0F6D64; }
QTabBar::tab:hover { color: $text; background-color: $surface_alt; }
QTabBar::tab:focus { border-color: $focus; }
QTabBar::tab:selected {
color: $indigo_pressed;
background-color: $indigo_pale;
border-color: $line_soft;
}
QTabBar::tab:disabled { color: $disabled_text; }
QScrollArea { border: 0; background-color: transparent; }
QScrollBar:vertical { background: transparent; width: 10px; margin: 2px; }
QScrollBar::handle:vertical { background: #C5CEC8; min-height: 30px; border-radius: 4px; }
QScrollBar::handle:vertical:hover { background: #9FAEA6; }
QMenuBar {
color: $text_soft;
background-color: $surface;
border-bottom: 1px solid $line;
}
QMenuBar::item { padding: 7px 10px; background-color: transparent; border-radius: 6px; }
QMenuBar::item:selected { color: $text; background-color: $raised; }
QMenu {
color: $text;
background-color: $raised;
border: 1px solid $line_soft;
border-radius: 10px;
padding: 6px;
}
QMenu::item {
min-width: 112px;
min-height: 32px;
padding: 0 12px 0 30px;
border-radius: 7px;
background-color: transparent;
}
QMenu::item:selected { color: #FFFFFF; background-color: $indigo_pressed; }
QMenu::item:pressed { 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; }
QMenu::separator { height: 1px; background-color: $line; margin: 6px 8px; }
QMenu::icon { left: 8px; }
QMenu::indicator { width: 16px; height: 16px; }
QCalendarWidget {
color: $text;
background-color: $surface;
border: 1px solid $line;
border-radius: 12px;
}
QCalendarWidget QWidget#qt_calendar_navigationbar {
background-color: $raised;
border-bottom: 1px solid $line;
}
QCalendarWidget QToolButton {
color: $text;
background-color: transparent;
border: 0;
border-radius: 7px;
}
QCalendarWidget QToolButton:hover { background-color: $indigo_pale; }
QCalendarWidget QSpinBox {
color: $text;
background-color: $surface_alt;
border: 1px solid $line;
}
QCalendarWidget QAbstractItemView:enabled {
color: $text_soft;
background-color: $surface;
selection-background-color: $indigo;
selection-color: #FFFFFF;
}
QCalendarWidget QAbstractItemView:disabled { color: $disabled_text; }
QScrollArea, QAbstractScrollArea {
border: 0;
background-color: transparent;
}
QScrollBar:vertical {
background: transparent;
width: 10px;
margin: 2px;
}
QScrollBar::handle:vertical {
background: $line;
min-height: 30px;
border-radius: 4px;
}
QScrollBar::handle:vertical:hover { background: $indigo_pressed; }
QScrollBar::handle:vertical:pressed { background: $indigo; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
QScrollBar:horizontal { background: transparent; height: 10px; margin: 2px; }
QScrollBar::handle:horizontal { background: #C5CEC8; min-width: 30px; border-radius: 4px; }
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; }
QScrollBar:horizontal {
background: transparent;
height: 10px;
margin: 2px;
}
QScrollBar::handle:horizontal {
background: $line;
min-width: 30px;
border-radius: 4px;
}
QScrollBar::handle:horizontal:hover { background: $indigo_pressed; }
QScrollBar::handle:horizontal:pressed { background: $indigo; }
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { background: transparent; }
QProgressBar { min-height: 6px; max-height: 6px; border: 0; border-radius: 3px; background: #E1E5E1; }
QProgressBar::chunk { border-radius: 3px; background-color: #168579; }
QProgressBar {
min-height: 6px;
max-height: 6px;
border: 0;
border-radius: 3px;
background: $line;
color: transparent;
}
QProgressBar::chunk { border-radius: 3px; background-color: $indigo; }
QSlider::groove:horizontal { height: 4px; background-color: $line; border-radius: 2px; }
QSlider::sub-page:horizontal { background-color: $indigo; border-radius: 2px; }
QSlider::handle:horizontal {
width: 14px;
margin: -5px 0;
background-color: $text;
border: 2px solid $indigo;
border-radius: 7px;
}
QSlider::handle:horizontal:hover { border-color: $focus; }
QLabel#StatusBadge {
padding: 4px 9px;
border: 1px solid transparent;
border-radius: 9px;
font-size: 11px;
font-weight: 700;
}
QLabel#StatusBadge[kind="neutral"] { color: #53635C; background-color: #E8ECE9; }
QLabel#StatusBadge[kind="success"] { color: #216348; background-color: #E1F1E9; }
QLabel#StatusBadge[kind="warning"] { color: #805714; background-color: #F8F0DA; }
QLabel#StatusBadge[kind="danger"] { color: #A43C35; background-color: #F8E8E5; }
QLabel#StatusBadge[kind="info"] { color: #2F5F7E; background-color: #E5EFF5; }
QLabel#StatusBadge[kind="accent"] { color: #0F6D64; background-color: #DDF1EC; }
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); }
QLabel#StatusBadge[kind="warning"] { color: $warning; background-color: $warning_pale; border-color: rgba(228, 185, 103, 72); }
QLabel#StatusBadge[kind="danger"] { color: $danger; background-color: $danger_pale; border-color: rgba(240, 120, 134, 72); }
QLabel#StatusBadge[kind="info"] { color: $info; background-color: $info_pale; border-color: rgba(120, 167, 255, 72); }
QLabel#StatusBadge[kind="accent"] { color: $indigo_hover; background-color: $indigo_pale; border-color: $line_soft; }
QWidget#EmptyState { background-color: transparent; }
QLabel#EmptyStateGlyph {
color: $indigo_hover;
background-color: $indigo_pale;
border: 1px solid $line_soft;
border-radius: 22px;
font-size: 28px;
font-weight: 500;
}
QFrame#MessageBanner {
border: 1px solid $line;
border-radius: 10px;
color: $text_soft;
}
QFrame#MessageBanner QLabel { background-color: transparent; }
QFrame#MessageBanner QLabel#MessageBannerIcon { font-weight: 700; }
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); }
QFrame#MessageBanner[kind="danger"] { color: $danger; background-color: $danger_pale; border-color: rgba(240, 120, 134, 82); }
QFrame#MessageBanner[kind="info"] QLabel { color: $info; }
QFrame#MessageBanner[kind="success"] QLabel { color: $success; }
QFrame#MessageBanner[kind="warning"] QLabel { color: $warning; }
QFrame#MessageBanner[kind="danger"] QLabel { color: $danger; }
QFrame#MessageBanner { border-radius: 10px; }
QFrame#MessageBanner[kind="info"] { background-color: #E5EFF5; border: 1px solid #C6DCE8; }
QFrame#MessageBanner[kind="success"] { background-color: #E1F1E9; border: 1px solid #C2E1D1; }
QFrame#MessageBanner[kind="warning"] { background-color: #F8F0DA; border: 1px solid #EADAAE; }
QFrame#MessageBanner[kind="danger"] { background-color: #F8E8E5; border: 1px solid #EBC6C1; }
QLabel#Toast {
color: #FFFFFF;
background-color: #17382F;
border: 1px solid #315147;
color: $text;
background-color: $raised;
border: 1px solid $line_soft;
border-radius: 11px;
padding: 11px 16px;
font-weight: 600;
}
QLabel#Toast[kind="danger"] { background-color: #8F3832; border-color: #A94740; }
QLabel#Toast[kind="success"] { background-color: #216348; border-color: #2B7759; }
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); }
QLabel#Toast[kind="warning"] { color: $warning; background-color: $warning_pale; border-color: rgba(228, 185, 103, 96); }
QLabel#Toast[kind="danger"] { color: $danger; background-color: $danger_pale; border-color: rgba(240, 120, 134, 96); }
QWidget#Sidebar { background-color: #17382F; }
QFrame#TopBar { background-color: #FCFBF8; border-bottom: 1px solid #D9DEDA; }
QFrame#BusyOverlay {
color: $text_soft;
background-color: rgba(245, 247, 251, 232);
border: 1px solid $line_soft;
border-radius: 16px;
}
QFrame#BusyOverlay QLabel { color: $muted; background-color: transparent; }
QFrame#BusyOverlay QProgressBar { background-color: $line; }
QWidget#Sidebar {
background-color: rgba(255, 255, 255, 250);
border-right: 1px solid $line;
}
QFrame#TopBar, QFrame#MultipleTabs {
background-color: rgba(255, 255, 255, 248);
border-bottom: 1px solid $line;
}
QLabel#UserAvatar {
min-width: 36px; max-width: 36px; min-height: 36px; max-height: 36px;
color: #0F6D64; background-color: #DDF1EC; border-radius: 18px;
font-size: 15px; font-weight: 700;
min-width: 36px;
max-width: 36px;
min-height: 36px;
max-height: 36px;
color: #FFFFFF;
background-color: $indigo_pressed;
border: 1px solid $line_soft;
border-radius: 18px;
font-size: 15px;
font-weight: 700;
}
QWidget#LoginBrandPanel {
background-color: qlineargradient(
x1: 0, y1: 0, x2: 1, y2: 1,
stop: 0 $surface,
stop: 1 $canvas_glow
);
border: 1px solid $line_soft;
border-radius: 22px;
}
QFrame#LoginCard {
background-color: $glass;
border: 1px solid $line_soft;
border-radius: 20px;
}
QWidget#LoginBrandPanel { background-color: #17382F; border-radius: 22px; }
QFrame#LoginCard { background-color: #FCFBF8; border: 1px solid #D9DEDA; border-radius: 20px; }
QFrame#BusyOverlay { background-color: rgba(244, 243, 239, 220); border-radius: 16px; }
QSplitter::handle { background-color: transparent; width: 8px; height: 8px; }
QSplitter::handle:hover { background-color: #DDE3DF; }
QToolTip { color: #FFFFFF; background-color: #17382F; border: 0; padding: 6px; }
QSplitter::handle:hover { background-color: $indigo_pressed; }
QToolTip {
color: $text;
background-color: $raised;
border: 1px solid $line_soft;
border-radius: 6px;
padding: 6px 8px;
}
"""
).substitute(COLORS)
def _apply_group(
palette: QPalette,
group: QPalette.ColorGroup,
colors: dict[QPalette.ColorRole, str],
) -> None:
for role, value in colors.items():
palette.setColor(group, role, QColor(value))
def apply_theme(app: QApplication) -> None:
"""Apply the global palette and stylesheet to ``app``."""
"""Apply the global Fusion palette and stylesheet to ``app``."""
app.setStyle("Fusion")
palette = QPalette()
palette.setColor(QPalette.ColorRole.Window, QColor(COLORS["canvas"]))
palette.setColor(QPalette.ColorRole.WindowText, QColor(COLORS["text"]))
palette.setColor(QPalette.ColorRole.Base, QColor("#FFFFFF"))
palette.setColor(QPalette.ColorRole.AlternateBase, QColor(COLORS["surface_alt"]))
palette.setColor(QPalette.ColorRole.Text, QColor(COLORS["text"]))
palette.setColor(QPalette.ColorRole.Button, QColor(COLORS["surface"]))
palette.setColor(QPalette.ColorRole.ButtonText, QColor(COLORS["text"]))
palette.setColor(QPalette.ColorRole.Highlight, QColor(COLORS["teal_pale"]))
palette.setColor(QPalette.ColorRole.HighlightedText, QColor(COLORS["ink"]))
palette.setColor(QPalette.ColorRole.PlaceholderText, QColor("#8A958F"))
active = {
QPalette.ColorRole.WindowText: COLORS["text"],
QPalette.ColorRole.Button: COLORS["surface_alt"],
QPalette.ColorRole.Light: "#FFFFFF",
QPalette.ColorRole.Midlight: COLORS["line"],
QPalette.ColorRole.Dark: COLORS["canvas"],
QPalette.ColorRole.Mid: COLORS["line"],
QPalette.ColorRole.Text: COLORS["text"],
QPalette.ColorRole.BrightText: "#FFFFFF",
QPalette.ColorRole.ButtonText: COLORS["text"],
QPalette.ColorRole.Base: COLORS["surface"],
QPalette.ColorRole.Window: COLORS["canvas"],
QPalette.ColorRole.Shadow: "#B7C0D2",
QPalette.ColorRole.Highlight: COLORS["indigo"],
QPalette.ColorRole.HighlightedText: "#FFFFFF",
QPalette.ColorRole.Link: COLORS["info"],
QPalette.ColorRole.LinkVisited: COLORS["indigo_hover"],
QPalette.ColorRole.AlternateBase: COLORS["surface_alt"],
QPalette.ColorRole.ToolTipBase: COLORS["raised"],
QPalette.ColorRole.ToolTipText: COLORS["text"],
QPalette.ColorRole.PlaceholderText: COLORS["muted"],
QPalette.ColorRole.Accent: COLORS["indigo"],
}
disabled = {
QPalette.ColorRole.WindowText: COLORS["disabled_text"],
QPalette.ColorRole.Button: COLORS["disabled_surface"],
QPalette.ColorRole.Light: COLORS["line"],
QPalette.ColorRole.Midlight: COLORS["line"],
QPalette.ColorRole.Dark: COLORS["canvas"],
QPalette.ColorRole.Mid: COLORS["line"],
QPalette.ColorRole.Text: COLORS["disabled_text"],
QPalette.ColorRole.BrightText: COLORS["muted"],
QPalette.ColorRole.ButtonText: COLORS["disabled_text"],
QPalette.ColorRole.Base: COLORS["disabled_surface"],
QPalette.ColorRole.Window: COLORS["canvas"],
QPalette.ColorRole.Shadow: "#C8CFDC",
QPalette.ColorRole.Highlight: COLORS["line"],
QPalette.ColorRole.HighlightedText: COLORS["disabled_text"],
QPalette.ColorRole.Link: COLORS["disabled_text"],
QPalette.ColorRole.LinkVisited: COLORS["disabled_text"],
QPalette.ColorRole.AlternateBase: COLORS["disabled_surface"],
QPalette.ColorRole.ToolTipBase: COLORS["raised"],
QPalette.ColorRole.ToolTipText: COLORS["disabled_text"],
QPalette.ColorRole.PlaceholderText: COLORS["disabled_text"],
QPalette.ColorRole.Accent: COLORS["line"],
}
_apply_group(palette, QPalette.ColorGroup.Active, active)
_apply_group(palette, QPalette.ColorGroup.Inactive, active)
_apply_group(palette, QPalette.ColorGroup.Disabled, disabled)
app.setPalette(palette)
app.setStyleSheet(GLOBAL_QSS)
+13 -3
View File
@@ -325,6 +325,12 @@ def run_async(
def friendly_error(error: Any) -> str:
text = str(error).strip()
lowered = text.lower()
if "certificate_verify_failed" in lowered or "self-signed certificate" in lowered:
return (
"服务器证书不受系统信任。若这是可信内网的自签名服务器,请展开“服务器设置”,"
"勾选“信任自签名证书(仅内网调试)”后重新登录,设置会自动应用。"
)
return text or "操作未完成,请稍后重试。"
@@ -396,13 +402,15 @@ class EmptyState(QWidget):
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("EmptyState")
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 44, 24, 44)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.setSpacing(8)
glyph = QLabel("")
glyph.setObjectName("EmptyStateGlyph")
glyph.setAlignment(Qt.AlignmentFlag.AlignCenter)
glyph.setStyleSheet("font-size: 30px; color: #9DAEA5;")
glyph.setFixedSize(44, 44)
layout.addWidget(glyph)
title_label = QLabel(title)
title_label.setProperty("role", "sectionTitle")
@@ -429,10 +437,11 @@ class MessageBanner(QFrame):
layout.setContentsMargins(12, 9, 12, 9)
layout.setSpacing(9)
self.icon = QLabel("i")
self.icon.setObjectName("MessageBannerIcon")
self.icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.icon.setFixedSize(20, 20)
self.icon.setStyleSheet("font-weight: 700;")
self.label = QLabel(text)
self.label.setObjectName("MessageBannerText")
self.label.setWordWrap(True)
layout.addWidget(self.icon)
layout.addWidget(self.label, 1)
@@ -498,6 +507,7 @@ class BusyOverlay(QFrame):
self.label = QLabel(text)
self.label.setProperty("role", "muted")
progress = QProgressBar()
progress.setObjectName("BusyOverlayProgress")
progress.setRange(0, 0)
progress.setFixedWidth(140)
layout.addWidget(self.label, 0, Qt.AlignmentFlag.AlignCenter)
@@ -568,7 +578,7 @@ class SortableTable(QTableWidget):
item = QTableWidgetItem(text)
item.setTextAlignment(column.alignment)
item.setData(Qt.ItemDataRole.UserRole, row)
item.setToolTip(text if len(text) > 18 else "")
item.setToolTip(text if "\n" in text or len(text) > 18 else "")
self.setItem(row_index, column_index, item)
self.setSortingEnabled(True)
if row_to_select >= 0: