新增
@@ -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
|
||||
@@ -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`。
|
||||
|
||||
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 80 KiB |
@@ -119,7 +119,7 @@ if is_macos:
|
||||
icon=None,
|
||||
bundle_identifier="com.zyt.doctor-workstation",
|
||||
info_plist={
|
||||
"CFBundleDisplayName": "臻阳堂医生工作站",
|
||||
"CFBundleDisplayName": "甄养堂医生工作站",
|
||||
"NSCameraUsageDescription": "用于视频面诊时采集医生画面。",
|
||||
"NSMicrophoneUsageDescription": "用于视频面诊时采集医生语音。",
|
||||
"NSHighResolutionCapable": True,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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 != (
|
||||
"外部改写"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||