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
+82
View File
@@ -0,0 +1,82 @@
@echo off
setlocal EnableExtensions DisableDelayedExpansion
set "PROJECT_ROOT=%~dp0"
set "PYTHON_EXE=%PROJECT_ROOT%.venv\Scripts\python.exe"
cd /d "%PROJECT_ROOT%" || goto :cd_failed
if not exist "%PYTHON_EXE%" goto :venv_missing
rem Always run the current source tree instead of an older packaged EXE.
set "PYTHONPATH=%PROJECT_ROOT%src"
set "PYTHONUNBUFFERED=1"
set "PYTHONDONTWRITEBYTECODE=1"
rem Keep a dedicated debug profile, but reuse it so account/server preferences persist.
set "DEBUG_PROFILE=%LOCALAPPDATA%\ZhenYangTang\DoctorWorkstation\Debug"
if not defined LOCALAPPDATA set "DEBUG_PROFILE=%PROJECT_ROOT%.debug-profile"
set "DOCTOR_CONFIG_DIR=%DEBUG_PROFILE%\config"
set "DOCTOR_LOG_DIR=%DEBUG_PROFILE%\logs"
set "DOCTOR_DEMO_MODE=true"
set "DOCTOR_LOG_LEVEL=DEBUG"
set "DOCTOR_VIDEO_MODE=embedded"
if not exist "%DOCTOR_CONFIG_DIR%" mkdir "%DOCTOR_CONFIG_DIR%" >nul 2>&1
if not exist "%DOCTOR_LOG_DIR%" mkdir "%DOCTOR_LOG_DIR%" >nul 2>&1
echo ============================================================
echo Doctor Workstation - Source Debug Mode
echo ============================================================
echo Project : %PROJECT_ROOT%
echo Logs : %DOCTOR_LOG_DIR%
echo Account : doctor
echo Password: doctor123
echo ============================================================
echo.
if /I "%~1"=="--check" goto :check
"%PYTHON_EXE%" -X faulthandler -m doctor_workstation
set "RESULT=%ERRORLEVEL%"
if "%RESULT%"=="0" goto :success
echo.
echo [ERROR] Doctor Workstation exited with code %RESULT%.
echo [INFO] Debug logs: %DOCTOR_LOG_DIR%
echo Press any key to close this window.
pause >nul
endlocal & exit /b %RESULT%
:check
"%PYTHON_EXE%" -c "import doctor_workstation; from doctor_workstation.ui.theme import COLORS; print('DEBUG_LAUNCHER_OK', COLORS['canvas'])"
set "RESULT=%ERRORLEVEL%"
if not "%RESULT%"=="0" goto :check_failed
echo.
echo [OK] Source imports and debug environment are ready.
endlocal & exit /b 0
:check_failed
echo.
echo [ERROR] Source import check failed with code %RESULT%.
pause >nul
endlocal & exit /b %RESULT%
:success
endlocal & exit /b 0
:venv_missing
echo.
echo [ERROR] Python virtual environment was not found:
echo %PYTHON_EXE%
echo.
echo Create or restore app\.venv and install the project dependencies first.
echo Press any key to close this window.
pause >nul
endlocal & exit /b 2
:cd_failed
echo [ERROR] Could not enter the project directory: %PROJECT_ROOT%
pause >nul
endlocal & exit /b 3
+7 -2
View File
@@ -1,4 +1,4 @@
# 臻阳堂医生工作站
# 甄养堂医生工作站
一个以 Python + PySide6 编写的跨平台医生桌面端,面向 Windows 10/11 与 macOS 13+。项目按现有 `admin` 源码的真实接口契约实现,覆盖登录、接诊台、我的处方库、已开处方、患者列表、问诊列表和腾讯云视频面诊。
@@ -25,10 +25,11 @@ Windows 打包机需预先安装 `uv` 与 Node.js 20+;脚本会自动处理项
- 账号密码登录、token 会话、记住账号(不保存密码)和退出登录。
- 登录后读取 `/adminapi/auth.admin/mySelf`,按 `permissions` 动态控制页面和操作按钮;`*` 超级权限兼容现有后台。
- 接诊台:今日待接诊/已过号、患者详情、医生备注、通知医助、完成接诊、发起视频面诊。
- 挂号列表:对齐管理端 `tcm/appointment/list`,支持状态 Tab、日期/确认/部门筛选、通话、完成、开方与取消挂号。
- 我的处方库:主方/辅方与公开范围筛选,药材动态编辑,模板所有权和增删改权限。
- 已开处方:处方号/患者/审核状态筛选、状态展示和只读详情。
- 患者列表:复用 `/firstvisit.myPatient/lists` 的服务端数据范围,不在客户端伪造医生或部门过滤。
- 问诊列表:按日期、状态和患者筛选,支持从有效预约发起视频
- 问诊列表:对齐管理端 `tcm/diagnosis`,支持诊单筛选、预约、开方、指派、日常记录与只读详情
- 腾讯视频:沿用现有项目的 `@trtc/calls-uikit-vue` 主链;UserSig 只从后端短时获取,客户端不包含 SDKSecretKey。当前仅支持隔离的 QtWebEngine 内嵌模式。
- PyInstaller Windows/macOS 构建脚本、macOS 摄像头/麦克风权限配置与自动化测试。
@@ -64,6 +65,10 @@ DOCTOR_DEMO_MODE=false
DOCTOR_VERIFY_SSL=true
```
如果可信内网调试服务器使用自签名证书,可在登录页展开“服务器设置”,临时勾选
“信任自签名证书(仅内网调试)”;点击登录时会自动应用当前设置。该选项会关闭
服务器身份校验,不得用于公网或生产环境。
程序会自动在地址末尾追加 `/adminapi`。它与现有管理端保持相同约定:
- 登录:`POST /login/account`,请求包含 `account``password``terminal=1`
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 80 KiB

+1 -1
View File
@@ -119,7 +119,7 @@ if is_macos:
icon=None,
bundle_identifier="com.zyt.doctor-workstation",
info_plist={
"CFBundleDisplayName": "",
"CFBundleDisplayName": "",
"NSCameraUsageDescription": "",
"NSMicrophoneUsageDescription": "",
"NSHighResolutionCapable": True,
+2 -1
View File
@@ -31,7 +31,8 @@ def render() -> list[Path]:
output.mkdir(parents=True, exist_ok=True)
paths: list[Path] = []
for width, height in ((1024, 640), (1440, 900)):
# Keep the two regression sizes and add a full-HD deliverable for design review.
for width, height in ((1024, 640), (1440, 900), (1920, 1080)):
repository = DemoDoctorRepository()
session = repository.login(
repository.DEMO_ACCOUNT,
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:
+21
View File
@@ -134,6 +134,27 @@ def test_get_retries_only_timeouts_then_returns_data() -> None:
assert attempts == 3
def test_get_bytes_downloads_relative_public_image_without_api_token() -> None:
"""Generated QR images bypass the JSON envelope and never leak the API token."""
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, headers={"content-type": "image/png"}, content=b"png-data")
with ApiClient(
"https://example.test/root",
token="private-token",
transport=httpx.MockTransport(handler),
) as client:
assert client.get_bytes("/uploads/qrcode.png") == b"png-data"
request = requests[0]
assert str(request.url) == "https://example.test/uploads/qrcode.png"
assert "token" not in request.headers
@pytest.mark.parametrize(
("code", "exception_type"),
[
+1 -1
View File
@@ -559,7 +559,7 @@ def test_keyboard_focus_has_a_visible_state(
assert focus_target.hasFocus()
assert application.focusWidget() is focus_target
assert 'QPushButton[appointmentDate="true"]:focus' in APPOINTMENT_DRAWER_QSS
assert "border-color: #79BBFF;" in APPOINTMENT_DRAWER_QSS
assert "border-color: #0891B2;" in APPOINTMENT_DRAWER_QSS
drawer.close()
host.close()
+339
View File
@@ -0,0 +1,339 @@
"""Parity contracts for the admin appointment list port."""
from __future__ import annotations
import os
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtWidgets import QApplication, QLabel
from doctor_workstation.core.errors import ApiProtocolError
from doctor_workstation.core.models import Appointment, PageResult
from doctor_workstation.core.permissions import PermissionSet
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import RemoteDoctorRepository
from doctor_workstation.ui.pages import appointments as appointments_module
from doctor_workstation.ui.pages.appointments import (
AppointmentsPage,
_diagnosis_id,
_video_patient_id,
prescription_action_label,
)
from doctor_workstation.ui.shell import _match_navigation
class RecordingClient:
def __init__(self) -> None:
self.get_calls: list[tuple[str, dict[str, Any]]] = []
self.post_calls: list[tuple[str, dict[str, Any]]] = []
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
self.get_calls.append((endpoint, dict(params or {})))
if endpoint == "doctor.appointment/detail":
return {"id": int((params or {}).get("id", 0)), "patient_name": "测试"}
if endpoint == "dept.dept/all":
return [{"id": 10, "name": "中医门诊", "children": []}]
return {"lists": [], "count": 0, "extend": {"status_count": {"1": 2}}}
def post(self, endpoint: str, payload: dict[str, Any] | None = None) -> Any:
self.post_calls.append((endpoint, dict(payload or {})))
if endpoint == "tcm.diagnosis/generateMiniProgramQrcode":
return {"qrcode_url": "https://example.test/uploads/video-qr.png"}
return {"ok": True}
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
def test_remote_appointment_list_and_detail_hit_admin_endpoints() -> None:
client = RecordingClient()
repo = RemoteDoctorRepository(client) # type: ignore[arg-type]
repo.list_appointments(
page_no=1,
page_size=15,
status=1,
start_date="2026-08-11",
end_date="2026-08-11",
include_status_counts=1,
diagnosis_confirmed="1",
assistant_dept_id=10,
patient_name="",
)
detail = repo.get_appointment_detail(101)
departments = repo.list_departments()
assert client.get_calls[0][0] == "doctor.appointment/lists"
assert client.get_calls[0][1]["include_status_counts"] == 1
assert client.get_calls[0][1]["assistant_dept_id"] == 10
assert "diag_scope_relax" not in client.get_calls[0][1]
assert client.get_calls[1] == ("doctor.appointment/detail", {"id": 101})
assert client.get_calls[2] == ("dept.dept/all", {})
assert detail["id"] == 101
assert departments[0]["id"] == 10
def test_unlimited_date_relaxes_today_scope_for_pending_status() -> None:
client = RecordingClient()
repo = RemoteDoctorRepository(client) # type: ignore[arg-type]
repo.list_appointments(status=1, diag_scope_relax=1, page_no=1, page_size=15)
params = client.get_calls[0][1]
assert "start_date" not in params
assert "end_date" not in params
assert "diag_scope_relax" not in params
def test_video_qr_uses_doctor_id_without_fake_diagnosis_id() -> None:
client = RecordingClient()
repo = RemoteDoctorRepository(client) # type: ignore[arg-type]
result = repo.generate_video_qrcode(
diagnosis_id=501,
doctor_id=88,
patient_id=301,
share_user_id=9,
)
endpoint, payload = client.post_calls[-1]
assert endpoint == "tcm.diagnosis/generateMiniProgramQrcode"
assert payload == {
"diagnosis_id": 501,
"doctor_id": 88,
"patient_id": 301,
"share_user_id": 9,
"mini_program_path": "pages/login/login",
}
assert result["qrcode_url"].endswith("video-qr.png")
def test_legacy_empty_string_prescription_response_is_scoped_to_not_found() -> None:
class LegacyClient(RecordingClient):
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
if endpoint == "tcm.prescription/getByAppointment":
raise ApiProtocolError("envelope must be an object", data="")
return super().get(endpoint, params)
repo = RemoteDoctorRepository(LegacyClient()) # type: ignore[arg-type]
assert repo.get_prescription_by_appointment(101) is None
class BrokenClient(LegacyClient):
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
raise ApiProtocolError("invalid JSON")
broken = RemoteDoctorRepository(BrokenClient()) # type: ignore[arg-type]
with pytest.raises(ApiProtocolError, match="invalid JSON"):
broken.get_prescription_by_appointment(101)
def test_shell_matches_appointment_list_by_route_not_shared_permission() -> None:
row = {
"name": "挂号列表",
"paths": "/appointments",
"component": "tcm/appointment/list",
"perms": "doctor.appointment/lists",
}
item = _match_navigation(row)
assert item is not None
assert item.key == "appointments"
reception = {
"name": "接诊台",
"paths": "/reception",
"component": "patient/reception/index",
"perms": "doctor.appointment/lists",
}
assert _match_navigation(reception).key == "reception" # type: ignore[union-attr]
def test_diagnosis_and_patient_ids_stay_distinct_for_video() -> None:
row = Appointment.from_dict(
{
"id": 101,
"patient_id": 301,
"diagnosis_id": 501,
"source_patient_id": 301,
"status": 1,
"has_prescription": 0,
}
)
assert _diagnosis_id(row) == 501
assert _video_patient_id(row) == 301
assert prescription_action_label(row) == "开方"
approved = Appointment.from_dict(
{
"id": 102,
"prescription_audit_status": 1,
"prescription_void_status": 0,
"has_prescription": 1,
}
)
assert prescription_action_label(approved) == "查看"
def test_appointments_page_default_query_is_today_pending(
application: QApplication,
) -> None:
repo = DemoDoctorRepository()
page = AppointmentsPage(
repo,
permissions=PermissionSet(
[
"doctor.appointment/lists",
"doctor.appointment/complete",
"doctor.appointment/cancel",
"doctor.appointment/prescription",
"doctor.appointment/addDoctorNote",
"tcm.diagnosis/edit",
"tcm.diagnosis/kaifang",
"tcm.diagnosis/videoQr",
]
),
current_user={"id": 1001, "name": "陈医生", "role_id": 1},
)
page.show()
application.processEvents()
page.refresh()
application.processEvents()
filters = page._query_filters()
assert filters["status"] == 1
assert filters["include_status_counts"] == 1
assert filters["start_date"] == filters["end_date"]
assert "diag_scope_relax" not in filters
assert page.table.rowCount() >= 1
def test_demo_appointment_status_counts_respect_date_scope() -> None:
repo = DemoDoctorRepository()
today = repo._today.isoformat()
result = repo.list_appointments(
page_no=1,
page_size=20,
start_date=today,
end_date=today,
include_status_counts=1,
)
assert isinstance(result, PageResult)
counts = result.extend.get("status_count", {})
assert int(counts.get("1", counts.get(1, 0))) >= 1
assert int(counts.get("3", counts.get(3, 0))) >= 1
def test_appointment_multiline_cells_receive_enough_row_height(
application: QApplication,
) -> None:
page = AppointmentsPage(
DemoDoctorRepository(),
permissions=PermissionSet(["doctor.appointment/lists"]),
current_user={"id": 1001, "role_id": 1},
)
page._loaded(
{
"lists": [
{
"id": 15534,
"patient_name": "张玉英",
"patient_phone": "13800138000",
"gender": 0,
"age": 42,
"height": 162,
"weight": 55,
"doctor_name": "徐国军",
"appointment_date": "2026-08-11",
"appointment_time": "14:30",
"assistant_name": "蒋露露",
"diagnosis_confirmed": 0,
"has_prescription": 0,
"status": 1,
"status_desc": "已预约",
"remark": "",
}
],
"count": 1,
},
page._generation,
False,
)
patient_text = page.table.item(0, 1).text()
appointment_text = page.table.item(0, 3).text()
assert patient_text.count("\n") == 2
assert appointment_text == "2026-08-11\n14:30"
required = 3 * max(16, page.table.fontMetrics().lineSpacing()) + 10
assert page.table.rowHeight(0) >= required
assert page.table.item(0, 1).toolTip() == patient_text
assert page.table.item(0, 3).toolTip() == appointment_text
page.close()
def test_video_qr_dialog_renders_downloaded_image_inside_app(
application: QApplication,
) -> None:
repo = DemoDoctorRepository()
page = AppointmentsPage(
repo,
permissions=PermissionSet(["tcm.diagnosis/videoQr"]),
current_user={"id": 1001, "role_id": 1},
)
url = "https://demo.invalid/qrcode/video/88.png"
dialog = page._build_qr_dialog(
{"patient_name": "鹿立核"},
url,
{"qrcode_url": url, "_image_bytes": repo.download_public_image(url)},
)
image = dialog.findChild(QLabel, "VideoQrImage")
assert image is not None
assert image.pixmap() is not None and not image.pixmap().isNull()
assert dialog.findChild(QLabel, "VideoQrImage").text() == ""
dialog.close()
page.close()
application.processEvents()
def test_prescription_case_snapshot_uses_keyword_diagnosis_id(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
requested: list[int] = []
opened: list[tuple[Any, Any]] = []
class Repository:
def get_diagnosis_detail(self, *, diagnosis_id: int) -> dict[str, Any]:
requested.append(diagnosis_id)
return {"id": diagnosis_id, "patient_name": "测试患者"}
def run_immediately(function: Any, **callbacks: Any) -> object:
try:
callbacks["on_success"](function())
except Exception as error: # pragma: no cover - assertion output is clearer
callbacks["on_error"](error)
finally:
callbacks["on_finished"]()
return object()
monkeypatch.setattr(appointments_module, "run_async", run_immediately)
page = AppointmentsPage(
Repository(),
permissions=PermissionSet(["tcm.diagnosis/kaifang"]),
current_user={"id": 9, "role_id": 1},
)
monkeypatch.setattr(
page,
"_open_prescription_editor",
lambda record, detail: opened.append((record, detail)),
)
page._begin_case_record_load(
{"id": 101, "appointment_id": 101, "diagnosis_id": 501, "patient_id": 501}
)
assert requested == [501]
assert opened and opened[0][1]["id"] == 501
page.close()
application.processEvents()
+5
View File
@@ -35,6 +35,11 @@ def test_config_update_validates_video_mode() -> None:
AppConfig().with_updates(video_mode="unknown")
def test_config_update_normalizes_ssl_boolean_strings() -> None:
assert AppConfig().with_updates(verify_ssl="false").verify_ssl is False
assert AppConfig(verify_ssl=False).with_updates(verify_ssl="true").verify_ssl is True
def test_runtime_directories_can_be_isolated_without_replacing_user_home(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
File diff suppressed because it is too large Load Diff
@@ -113,7 +113,6 @@ def test_remote_detail_actions_use_exact_confirmed_endpoints() -> None:
if endpoint == "tcm.diagnosis/generateMiniProgramQrcode"
]
assert qr_payloads[0] == {
"diagnosis_id": 1001,
"doctor_id": 1001,
"patient_id": 301,
"share_user_id": 1001,
+132 -3
View File
@@ -15,7 +15,6 @@ from PySide6.QtWidgets import (
QFrame,
QLabel,
QPushButton,
QRadioButton,
QScrollArea,
QToolButton,
QWidget,
@@ -187,6 +186,63 @@ class VisualRepository:
{"name": "口干", "value": "口干"},
{"name": "口苦", "value": "口苦"},
],
"water_intake": [
{"name": "偏少", "value": "偏少"},
{"name": "正常", "value": "正常"},
],
"weight_change": [
{"name": "下降", "value": "下降"},
{"name": "上升", "value": "上升"},
],
"fatty_liver_degree": [
{"name": "轻度", "value": "轻度"},
{"name": "中度", "value": "中度"},
],
"diet_condition": [
{"name": "纳差", "value": "纳差"},
{"name": "多食易饥", "value": "多食易饥"},
],
"body_feeling": [
{"name": "乏力", "value": "乏力"},
{"name": "肢体麻木", "value": "肢体麻木"},
],
"sleep_condition": [
{"name": "入睡困难", "value": "入睡困难"},
{"name": "多梦", "value": "多梦"},
{"name": "易醒", "value": "易醒"},
],
"eye_condition": [
{"name": "视物模糊", "value": "视物模糊"},
{"name": "眼干", "value": "眼干"},
],
"head_feeling": [
{"name": "头痛", "value": "头痛"},
{"name": "头晕", "value": "头晕"},
],
"sweat_condition": [
{"name": "自汗", "value": "自汗"},
{"name": "盗汗", "value": "盗汗"},
],
"skin_condition": [
{"name": "皮肤瘙痒", "value": "皮肤瘙痒"},
{"name": "皮肤干燥", "value": "皮肤干燥"},
],
"urine_condition": [
{"name": "尿频", "value": "尿频"},
{"name": "夜尿多", "value": "夜尿多"},
],
"stool_condition": [
{"name": "便秘", "value": "便秘"},
{"name": "便溏", "value": "便溏"},
],
"kidney_condition": [
{"name": "腰酸", "value": "腰酸"},
{"name": "腰痛", "value": "腰痛"},
],
"past_history": [
{"name": "高血压", "value": "高血压"},
{"name": "冠心病", "value": "冠心病"},
],
}
return dictionaries.get(dictionary_type, [])
@@ -661,7 +717,8 @@ def test_semantic_form_controls_keep_desktop_grid_and_canonical_diagnosis_type(
)
assert isinstance(dialog.edit_fields["gender"], DiagnosisChoiceButtons)
assert len(dialog.edit_fields["gender"].findChildren(QRadioButton)) == 2
assert len(dialog.edit_fields["gender"]._buttons) == 2
assert all(button.isCheckable() for button in dialog.edit_fields["gender"]._buttons)
assert isinstance(dialog.edit_fields["local_hospital_diagnosis"], DiagnosisChoiceButtons)
assert isinstance(dialog.edit_fields["age"], DiagnosisNumberEdit)
assert isinstance(dialog.edit_fields["height"], DiagnosisNumberEdit)
@@ -677,8 +734,9 @@ def test_semantic_form_controls_keep_desktop_grid_and_canonical_diagnosis_type(
]
assert diagnosis_type.toPlainText() == "follow_up"
assert "diagnosis_type" in repository.dictionary_calls
# 1024×60% drawer keeps a two-column grid; only phone-narrow widths stack.
assert dialog._form_narrow is False
assert any(len(fields) >= 3 for _layout, fields, _remainder in dialog._form_rows)
assert any(len(fields) >= 2 for _layout, fields, _remainder in dialog._form_rows)
_select_tab(dialog, "notes")
assert not dialog.save_button.isVisibleTo(dialog)
@@ -690,6 +748,77 @@ def test_semantic_form_controls_keep_desktop_grid_and_canonical_diagnosis_type(
dialog.close()
def test_hpi_choice_chips_are_visible_after_dictionary_load(
application: QApplication,
) -> None:
"""Regression: wrapped choice chips must not clip to blank white space."""
dialog = _open_dialog(application, (1024, 640), mode="edit")
diet = dialog.edit_fields["diet_condition"]
sleep = dialog.edit_fields["sleep_condition"]
assert isinstance(diet, DiagnosisChoiceButtons)
assert isinstance(sleep, DiagnosisChoiceButtons)
assert len(diet._buttons) >= 2
assert diet.height() >= 32
assert diet.minimumHeight() >= diet.heightForWidth(max(160, diet.width()))
visible_chips = [button for button in diet._buttons if button.isVisibleTo(dialog)]
assert visible_chips
assert all(button.height() >= 24 for button in visible_chips)
# Chips must sit inside the choice host, not below a clipped viewport.
host_bottom = diet.mapTo(dialog.drawer_panel, diet.rect().bottomLeft()).y()
chip_bottom = visible_chips[0].mapTo(dialog.drawer_panel, visible_chips[0].rect().bottomLeft()).y()
assert chip_bottom <= host_bottom + 2
dialog.close()
application.processEvents()
def test_choice_chips_keep_visible_checked_style_when_readonly(
application: QApplication,
) -> None:
"""Selected chips must stay cyan even when the form is view-only locked."""
dialog = _open_dialog(application, (1024, 640), mode="edit")
diet = dialog.edit_fields["diet_condition"]
assert isinstance(diet, DiagnosisChoiceButtons)
diet.setPlainText("纳差")
selected = next(button for button in diet._buttons if button.isChecked())
application.processEvents()
# Sample the pad (not glyph center) so white text does not hide the fill.
enabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
assert enabled_color.name().lower() == "#cffafe"
diet.setReadOnly(True)
application.processEvents()
disabled_color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
assert selected.isChecked()
assert selected.isEnabled()
assert diet.isReadOnly()
assert disabled_color.name().lower() == "#cffafe"
dialog.close()
application.processEvents()
def test_view_only_drawer_shows_selected_choice_chips(
application: QApplication,
) -> None:
"""Admin openViewOnly keeps the 病历 form with selected chips visible."""
dialog = _open_dialog(application, (1024, 640), mode="viewOnly")
assert dialog.view_stack.currentWidget() is dialog.drawer_overlay
assert dialog._view_only is True
assert not dialog._editable
diet = dialog.edit_fields["diet_condition"]
assert isinstance(diet, DiagnosisChoiceButtons)
diet.setPlainText("纳差")
selected = next(button for button in diet._buttons if button.isChecked())
application.processEvents()
color = selected.grab().toImage().pixelColor(6, selected.height() // 2)
assert color.name().lower() == "#cffafe"
assert diet.isReadOnly()
assert not dialog.save_button.isVisibleTo(dialog)
dialog.close()
application.processEvents()
def test_view_only_uses_ordinary_detail_and_standalone_uses_readonly_detail(
application: QApplication,
) -> None:
File diff suppressed because it is too large Load Diff
+56 -1
View File
@@ -18,7 +18,11 @@ from doctor_workstation.ui.diagnosis_media import (
safe_http_url,
)
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog, OrderDetailDrawer
from doctor_workstation.ui.dialogs.diagnosis import (
DiagnosisDialog,
OrderDetailDrawer,
present_order_detail,
)
@pytest.fixture(scope="module")
@@ -214,6 +218,31 @@ def test_order_detail_is_eighty_percent_readonly_drawer_with_real_sections(
application.processEvents()
def test_present_order_detail_shared_entry_matches_admin_drawer_sections(
application: QApplication,
) -> None:
host = QFrame()
host.resize(1100, 720)
host.show()
application.processEvents()
drawer = present_order_detail(
host,
_rich_order(),
order_id=801,
permissions=["tcm.prescriptionOrder/logs", "tcm.prescriptionOrder/detail"],
exec_=False,
)
application.processEvents()
assert isinstance(drawer, OrderDetailDrawer)
labels = [label.text() for label in drawer.findChildren(QLabel)]
for section in ("金额概览", "处方详情", "收款记录", "履约与收货信息", "物流轨迹", "操作日志"):
assert section in labels
assert "RX-20260811-0801" in " ".join(labels)
drawer.close()
host.close()
application.processEvents()
def test_order_detail_missing_fields_use_explicit_empty_states_without_fake_zero(
application: QApplication,
) -> None:
@@ -287,6 +316,9 @@ def test_recording_preference_inline_height_alternates_and_safe_external_open(
assert player.maximumHeight() == 180
assert player.property("maximumPlaybackHeight") == 180
assert player._source_attached is False
assert player.player is None
assert player.audio_output is None
assert player.video is None
alternate_buttons = cell.findChildren(QPushButton, "DiagnosisRecordingAlternateLink")
assert [button.text() for button in alternate_buttons] == [
"COS HLS 1",
@@ -309,6 +341,29 @@ def test_recording_preference_inline_height_alternates_and_safe_external_open(
application.processEvents()
def test_recording_rows_do_not_eagerly_create_native_players(
application: QApplication,
) -> None:
cells = [
RecordingPlaybackCell(
[f"https://media.example.invalid/replay-{index}.mp4"],
record_id=index,
)
for index in range(40)
]
application.processEvents()
inline_players = [cell.inline_player for cell in cells]
assert all(player is not None for player in inline_players)
assert all(player.player is None for player in inline_players if player is not None)
assert all(player.audio_output is None for player in inline_players if player is not None)
assert all(player.video is None for player in inline_players if player is not None)
for cell in cells:
cell.close()
application.processEvents()
def test_video_table_embeds_player_and_preserves_row_bound_upload(
application: QApplication,
) -> None:
+55
View File
@@ -0,0 +1,55 @@
"""Dictionary payload contracts for diagnosis choice fields."""
from __future__ import annotations
from typing import Any
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import RemoteDoctorRepository, _dictionary_rows
from doctor_workstation.ui.dialogs.diagnosis import _CHOICE_DICTIONARIES, _dictionary_choices
class RecordingClient:
def __init__(self, payload: Any) -> None:
self.payload = payload
self.get_calls: list[tuple[str, dict[str, Any]]] = []
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
self.get_calls.append((endpoint, dict(params or {})))
return self.payload
def test_dictionary_rows_unwrap_admin_type_map() -> None:
rows = _dictionary_rows(
{
"sleep_condition": [
{"name": "入睡困难", "value": "入睡困难"},
{"name": "多梦", "value": "多梦"},
]
},
"sleep_condition",
)
assert [row["value"] for row in rows] == ["入睡困难", "多梦"]
def test_remote_get_dictionary_uses_admin_dict_shape() -> None:
client = RecordingClient(
{
"appetite": [
{"name": "口干", "value": "口干"},
{"name": "口苦", "value": "口苦"},
]
}
)
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
rows = repository.get_dictionary("appetite")
assert client.get_calls == [("config/dict", {"type": "appetite"})]
assert _dictionary_choices(rows, "appetite") == [("口干", "口干"), ("口苦", "口苦")]
def test_demo_exposes_all_diagnosis_choice_dictionaries() -> None:
repository = DemoDoctorRepository()
for dictionary_type, _multiple in _CHOICE_DICTIONARIES.values():
rows = repository.get_dictionary(dictionary_type)
assert rows, dictionary_type
assert _dictionary_choices(rows, dictionary_type)
+3 -3
View File
@@ -51,7 +51,7 @@ def test_complete_appointment_mutates_all_related_views(
repository.complete_appointment(101)
completed = repository.list_appointments(status=3).items
assert [item.id for item in completed] == [101]
assert [item.id for item in completed] == [101, 104]
patient = repository.list_patients(keyword="林晓岚").items[0]
assert patient.appointment_status == 3
assert patient.status_filter == "completed"
@@ -129,8 +129,8 @@ def test_demo_pagination_and_returned_copies(repository: DemoDoctorRepository) -
"""Pagination metadata is stable and callers cannot mutate repository state."""
page = repository.list_appointments(page_no=1, page_size=1)
assert page.total == 3
assert page.pages == 3
assert page.total == 5
assert page.pages == 5
page.items[0].patient_name = "外部改写"
assert repository.list_appointments(page_no=1, page_size=1).items[0].patient_name != (
"外部改写"
+9
View File
@@ -38,6 +38,15 @@ def test_windows_one_click_entrypoints_and_release_pipeline() -> None:
assert "explorer.exe" in read("Build_DoctorWorkstation.bat")
def test_debug_launcher_reuses_an_isolated_persistent_profile() -> None:
debug_script = read("Debug_DoctorWorkstation.bat")
assert "%LOCALAPPDATA%\\ZhenYangTang\\DoctorWorkstation\\Debug" in debug_script
assert "DOCTOR_CONFIG_DIR=%DEBUG_PROFILE%\\config" in debug_script
assert "DOCTOR_LOG_DIR=%DEBUG_PROFILE%\\logs" in debug_script
assert "%RANDOM%" not in debug_script
def test_macos_one_click_entrypoints_and_release_pipeline() -> None:
for name in (
"run_macos.command",
+2
View File
@@ -108,6 +108,7 @@ def test_shell_resolves_dynamic_menu_order_visibility_and_canonical_permissions(
assert _resolve_navigation([], permissions, demo_mode=False) == []
assert [item.key for item, _title in _resolve_navigation([], permissions, demo_mode=True)] == [
"reception",
"appointments",
"prescriptions",
"patients",
"consultations",
@@ -547,6 +548,7 @@ def test_shell_uses_demo_session_menu_and_fits_minimum_window(
assert shell.size().height() == 640
assert {item.key for item in NAVIGATION} == {
"reception",
"appointments",
"prescription_library",
"prescriptions",
"patients",
+62 -1
View File
@@ -19,6 +19,8 @@ from doctor_workstation.ui.dialogs.prescription import (
PrescriptionOrderDialog,
PrescriptionTemplateDialog,
RemoteMedicineComboBox,
build_prescription_clinical_diagnosis,
build_prescription_visit_no,
parse_pasted_herbs,
render_prescription_html,
)
@@ -109,6 +111,22 @@ def test_paste_parser_matches_common_admin_recipe_forms() -> None:
]
def test_prescription_seed_helpers_match_admin_visit_no_and_clinical() -> None:
assert build_prescription_visit_no(diagnosis_id=15534) == "1K00015534"
assert build_prescription_visit_no(appointment_id=401, diagnosis_id=15534) == "1K00000401"
assert build_prescription_visit_no() == ""
assert build_prescription_clinical_diagnosis({"diagnosis_type": "follow_up"}) == "复诊"
assert build_prescription_clinical_diagnosis({"clinical_diagnosis": "follow_up"}) == "复诊"
assert (
build_prescription_clinical_diagnosis(
{"symptoms": "肝郁脾虚", "diagnosis_type": "follow_up"}
)
== "肝郁脾虚"
)
assert build_prescription_clinical_diagnosis({"clinical_diagnosis": "气阴两虚"}) == "气阴两虚"
def test_remote_medicine_selector_rejects_new_free_text(
application: QApplication,
) -> None:
@@ -374,6 +392,11 @@ def test_order_payload_and_a4_print_document(
"dose_unit": "",
"usage_days": 7,
"times_per_day": 2,
"dosage_amount": 5,
"dosage_bag_count": 2,
"dosage_unit": "g",
"usage_way": "温水送服",
"usage_time": "饭后",
"herbs": [
{"name": "黄芪", "dosage": 15, "formula_type": "主方"},
{"name": "酸枣仁", "dosage": 12, "formula_type": "辅方"},
@@ -399,13 +422,51 @@ def test_order_payload_and_a4_print_document(
assert "林晓岚" in rendered
assert "黄芪" in rendered
assert "酸枣仁" in rendered
assert "服药前请核对姓名、电话、医生等信息" in rendered
assert "Rp." in rendered
assert "药房联" in rendered
assert "主方" in rendered
assert "辅方" in rendered
assert "105克" in rendered
assert "84克" in rendered
assert "单剂量:</span> 27克" in rendered
assert "每天2次, 一次2袋, 每袋5g, 温水送服, 饭后" in rendered
viewer = PrescriptionDetailDialog(prescription)
assert "中医处方笺" in viewer.document.toHtml()
document_html = viewer.document.toHtml()
assert "药房联" in document_html
assert "用药 (单剂)" in document_html
viewer.close()
order.close()
application.processEvents()
def test_prescription_detail_can_open_immutable_case_record_tab(
application: QApplication,
) -> None:
prescription = {
"id": 12,
"diagnosis_id": 6,
"patient_name": "林晓岚",
"case_record": {
"patient_name": "林晓岚",
"phone": "13800138000",
"local_hospital_name": "市中医院",
"symptoms": "口渴乏力",
"tongue_coating": "舌红少苔",
},
}
viewer = PrescriptionDetailDialog(prescription, initial_tab="case")
assert viewer.tabs.count() == 2
assert viewer.tabs.tabText(viewer.tabs.currentIndex()) == "详细病历"
assert viewer.case_document is not None
case_html = viewer.case_document.toHtml()
assert "市中医院" in case_html
assert "口渴乏力" in case_html
viewer.close()
application.processEvents()
def test_demo_repository_pages_render_offscreen(
application: QApplication,
immediate_async: None,

Some files were not shown because too many files have changed in this diff Show More