This commit is contained in:
Your Name
2026-08-18 14:08:38 +08:00
parent 8b9df1154c
commit bc1228a310
77 changed files with 10763 additions and 1181 deletions
+68 -19
View File
@@ -138,20 +138,20 @@ class DemoVideoDialog(QDialog):
self.setWindowTitle("视频面诊 · 演示模式")
self.setMinimumSize(760, 520)
self.resize(980, 660)
self.setModal(False)
self.setStyleSheet(
"QDialog{background:#F7F9FE;color:#111F46;}"
"QLabel{color:#111F46;}"
"QFrame#RemoteStage{background:#0E1421;border:1px solid #29334F;border-radius:16px;}"
"QFrame#RemoteStage QLabel{color:#F7F9FE;}"
"QFrame#LocalStage{background:#151D31;border:1px solid #3F4E75;border-radius:12px;}"
"QPushButton{min-width:96px;min-height:40px;padding:0 16px;border-radius:9px;"
"background:#FFFFFF;color:#3F4E75;border:1px solid #E6EAF5;font-weight:600;}"
"QPushButton:hover{color:#4451E2;background:#F0F2FF;border-color:#5761F4;}"
"QPushButton:checked{color:#FFFFFF;background:#5761F4;border-color:#5761F4;}"
"QPushButton#Hangup{color:#FFFFFF;background:#F15B67;border-color:#F15B67;}"
"QPushButton#Hangup:hover{background:#D94857;border-color:#D94857;}"
)
self.setModal(False)
self.setStyleSheet(
"QDialog{background:#F7F9FE;color:#111F46;}"
"QLabel{color:#111F46;}"
"QFrame#RemoteStage{background:#0E1421;border:1px solid #29334F;border-radius:16px;}"
"QFrame#RemoteStage QLabel{color:#F7F9FE;}"
"QFrame#LocalStage{background:#151D31;border:1px solid #3F4E75;border-radius:12px;}"
"QPushButton{min-width:96px;min-height:40px;padding:0 16px;border-radius:9px;"
"background:#FFFFFF;color:#3F4E75;border:1px solid #E6EAF5;font-weight:600;}"
"QPushButton:hover{color:#4451E2;background:#F0F2FF;border-color:#5761F4;}"
"QPushButton:checked{color:#FFFFFF;background:#5761F4;border-color:#5761F4;}"
"QPushButton#Hangup{color:#FFFFFF;background:#F15B67;border-color:#F15B67;}"
"QPushButton#Hangup:hover{background:#D94857;border-color:#D94857;}"
)
root = QVBoxLayout(self)
root.setContentsMargins(22, 18, 22, 22)
@@ -162,7 +162,7 @@ class DemoVideoDialog(QDialog):
header.addWidget(title)
header.addStretch(1)
demo = QLabel("● 演示模式 · 未连接腾讯云")
demo.setStyleSheet("color:#7886AA;font-size:12px;")
demo.setStyleSheet("color:#7886AA;font-size:12px;")
header.addWidget(demo)
self.duration_label = QLabel("00:00")
self.duration_label.setStyleSheet("font-weight:700;")
@@ -177,8 +177,8 @@ class DemoVideoDialog(QDialog):
avatar = QLabel((patient_name or "患")[:1])
avatar.setAlignment(Qt.AlignmentFlag.AlignCenter)
avatar.setFixedSize(104, 104)
avatar.setStyleSheet(
"background:#F0F2FF;color:#5761F4;border-radius:52px;font-size:42px;font-weight:700;"
avatar.setStyleSheet(
"background:#F0F2FF;color:#5761F4;border-radius:52px;font-size:42px;font-weight:700;"
)
stage_layout.addWidget(avatar, 0, Qt.AlignmentFlag.AlignHCenter)
waiting = QLabel("等待患者接听…")
@@ -187,7 +187,7 @@ class DemoVideoDialog(QDialog):
stage_layout.addWidget(waiting)
hint = QLabel("生产模式将通过后端短时 UserSig 初始化腾讯 TUICallKit")
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
hint.setStyleSheet("color:#A4ADC3;font-size:12px;")
hint.setStyleSheet("color:#A4ADC3;font-size:12px;")
stage_layout.addWidget(hint)
stage_layout.addStretch(1)
@@ -197,7 +197,7 @@ class DemoVideoDialog(QDialog):
local_layout = QVBoxLayout(local)
local_label = QLabel("医生画面")
local_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
local_label.setStyleSheet("color:#D9E0F2;font-weight:600;")
local_label.setStyleSheet("color:#D9E0F2;font-weight:600;")
local_layout.addWidget(local_label)
root.addWidget(stage, 1)
@@ -788,6 +788,53 @@ class ApplicationController(QObject):
self.remote_repository.client.close()
def _prefer_native_tls_backend() -> None:
"""Keep Qt's HTTPS stack off OpenSSL.
PySide6 on Windows ships no OpenSSL DLLs of its own, so Qt's OpenSSL
TLS backend resolves to CPython's ``libcrypto-3-x64.dll`` and ends up
sharing one OpenSSL instance with the httpx stack. Concurrent use from
both stacks has crashed the process inside libcrypto (access violation
at a stable offset) while pages downloaded images through
``QNetworkAccessManager``. Route Qt network requests through the
native Schannel backend instead, which uses the Windows certificate
store and never touches OpenSSL.
"""
with suppress(Exception):
from PySide6.QtNetwork import QSslSocket
backends = QSslSocket.availableBackends()
if "schannel" in backends and "openssl" in backends:
QSslSocket.setActiveBackend("schannel")
_CRASH_LOG_HANDLE: Any = None
def _install_crash_handler(config: AppConfig) -> None:
"""Write per-thread Python tracebacks of native crashes into the log dir.
Debug launchers already pass ``-X faulthandler`` and dump to stderr;
keep that behavior and only redirect into ``crash.log`` for packaged
or plain runs where stderr is lost.
"""
global _CRASH_LOG_HANDLE
try:
import faulthandler
if faulthandler.is_enabled():
return
path = config.log_dir / "crash.log"
handle = path.open("a", encoding="utf-8", buffering=1)
handle.write(f"\n=== process started {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n")
faulthandler.enable(file=handle, all_threads=True)
_CRASH_LOG_HANDLE = handle
except Exception:
LOGGER.exception("crash handler could not be installed")
def _create_application(argv: list[str]) -> QApplication:
with suppress(AttributeError):
QGuiApplication.setHighDpiScaleFactorRoundingPolicy(
@@ -796,6 +843,7 @@ def _create_application(argv: list[str]) -> QApplication:
with suppress(AttributeError):
QApplication.setAttribute(Qt.ApplicationAttribute.AA_DontCreateNativeWidgetSiblings, True)
application = QApplication(argv)
_prefer_native_tls_backend()
_install_chinese_translations(application)
application.setApplicationName("甄养堂医生工作站")
application.setApplicationDisplayName("甄养堂医生工作站")
@@ -814,6 +862,7 @@ def main(argv: list[str] | None = None) -> int:
config = AppConfig.load()
configure_logging(config.log_dir, config.log_level)
_install_crash_handler(config)
LOGGER.info("doctor workstation starting", extra={"demo_mode": config.demo_mode})
raw_argv = list(sys.argv if argv is None else argv)
smoke_test = "--smoke-test" in raw_argv
+169 -37
View File
@@ -2,9 +2,10 @@
from __future__ import annotations
import json
import time
from collections.abc import Callable, Mapping
from threading import RLock
from collections.abc import Callable, Iterator, Mapping
from threading import Lock, RLock
from typing import Any
from urllib.parse import urljoin, urlsplit, urlunsplit
@@ -22,6 +23,15 @@ from doctor_workstation.core.errors import (
WorkWechatBindingRequiredError,
)
# One shared transport lock serializes every HTTP exchange in the process.
# The GUI fires page loads, dialog bundles and polling timers from many
# QThreadPool workers that all share a single ``httpx.Client``. Concurrent
# TLS handshakes on one shared SSLContext have repeatedly crashed the
# process inside libcrypto (access violation at a stable offset), so all
# network traffic now goes through this single lock. Requests stay
# off-thread, so the GUI never blocks; only the network itself is serialized.
_HTTP_TRANSPORT_LOCK = Lock()
class ApiClient:
"""A small, testable client implementing the admin API contract.
@@ -62,6 +72,8 @@ class ApiClient:
self._token = token.strip()
self._lock = RLock()
self._owns_client = client is None
self._stream_transport = transport
self._verify = verify
self._client = client or httpx.Client(transport=transport, verify=verify)
@staticmethod
@@ -162,6 +174,64 @@ class ApiClient:
headers=headers,
)
def post_event_stream(
self,
endpoint: str,
payload: Mapping[str, Any],
*,
timeout: float | httpx.Timeout | None = None,
cancelled: Callable[[], bool] | None = None,
) -> Iterator[dict[str, Any]]:
"""POST JSON and yield parsed server-sent events on an isolated client.
Streaming deliberately does not use ``_HTTP_TRANSPORT_LOCK`` or the
process-wide JSON client. A diagnosis response can remain open for
more than a minute and must not block unrelated page requests.
"""
url = self._endpoint_url(endpoint)
headers = self._headers({"Accept": "text/event-stream"})
request_timeout = self.timeout if timeout is None else timeout
try:
with httpx.Client(
transport=self._stream_transport,
verify=self._verify,
) as stream_client, stream_client.stream(
"POST",
url,
json=dict(payload),
headers=headers,
timeout=request_timeout,
) as response:
request_id = self._request_id(response)
if not 200 <= response.status_code < 300:
raise ApiHttpError(
f"API returned HTTP {response.status_code}",
status_code=response.status_code,
request_id=request_id,
)
content_type = response.headers.get("content-type", "").lower()
if "text/event-stream" not in content_type:
response.read()
if "json" in content_type:
self._unwrap(response)
raise ApiProtocolError(
"API response is not an event stream",
status_code=response.status_code,
request_id=request_id,
)
yield from _iter_event_stream(response.iter_lines(), cancelled=cancelled)
except httpx.TimeoutException as exc:
raise ApiTimeoutError(
f"POST {endpoint} stream timed out",
data={"method": "POST", "endpoint": endpoint},
) from exc
except httpx.RequestError as exc:
raise ApiTransportError(
f"POST {endpoint} stream failed: {exc}",
data={"method": "POST", "endpoint": endpoint},
) from exc
def get_bytes(self, url: str, *, max_bytes: int = 5 * 1024 * 1024) -> bytes:
"""Download a public binary asset without applying the JSON envelope contract."""
@@ -175,12 +245,13 @@ class ApiClient:
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,
)
with _HTTP_TRANSPORT_LOCK:
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:
@@ -253,34 +324,35 @@ class ApiClient:
attempts = self.max_retries + 1 if verb == "GET" else 1
response: httpx.Response | None = None
request_timeout = self.timeout if timeout is None else timeout
for attempt in range(attempts):
try:
response = self._client.request(
verb,
url,
params=dict(params) if params is not None else None,
json=dict(json) if verb == "POST" and json is not None else None,
data=dict(data) if verb == "POST" and data is not None else None,
files=dict(files) if files is not None else None,
headers=request_headers,
timeout=request_timeout,
)
break
except httpx.TimeoutException as exc:
if attempt + 1 < attempts:
delay = self.retry_backoff * (2**attempt)
if delay:
self._sleep(delay)
continue
raise ApiTimeoutError(
f"{verb} {endpoint} timed out after {attempt + 1} attempt(s)",
data={"method": verb, "endpoint": endpoint, "attempts": attempt + 1},
) from exc
except httpx.RequestError as exc:
raise ApiTransportError(
f"{verb} {endpoint} failed: {exc}",
data={"method": verb, "endpoint": endpoint},
) from exc
with _HTTP_TRANSPORT_LOCK:
for attempt in range(attempts):
try:
response = self._client.request(
verb,
url,
params=dict(params) if params is not None else None,
json=dict(json) if verb == "POST" and json is not None else None,
data=dict(data) if verb == "POST" and data is not None else None,
files=dict(files) if files is not None else None,
headers=request_headers,
timeout=request_timeout,
)
break
except httpx.TimeoutException as exc:
if attempt + 1 < attempts:
delay = self.retry_backoff * (2**attempt)
if delay:
self._sleep(delay)
continue
raise ApiTimeoutError(
f"{verb} {endpoint} timed out after {attempt + 1} attempt(s)",
data={"method": verb, "endpoint": endpoint, "attempts": attempt + 1},
) from exc
except httpx.RequestError as exc:
raise ApiTransportError(
f"{verb} {endpoint} failed: {exc}",
data={"method": verb, "endpoint": endpoint},
) from exc
if response is None: # Defensive; the loop always returns or raises.
raise ApiTransportError(f"{verb} {endpoint} produced no response")
return self._unwrap(response)
@@ -289,7 +361,8 @@ class ApiClient:
"""Close the internally-created HTTP transport."""
if self._owns_client:
self._client.close()
with _HTTP_TRANSPORT_LOCK:
self._client.close()
def __enter__(self) -> ApiClient:
"""Return this client for use as a context manager."""
@@ -414,3 +487,62 @@ class ApiClient:
def _mapping(value: object) -> Mapping[str, Any]:
return value if isinstance(value, Mapping) else {}
def _iter_event_stream(
lines: Iterator[str],
*,
cancelled: Callable[[], bool] | None = None,
) -> Iterator[dict[str, Any]]:
"""Parse SSE fields, including multi-line data and a final unterminated event."""
event_name = ""
event_id = ""
data_lines: list[str] = []
def build_event() -> dict[str, Any] | None:
nonlocal event_name, event_id, data_lines
if not event_name and not data_lines:
event_id = ""
return None
raw_data = "\n".join(data_lines)
try:
data: Any = json.loads(raw_data) if raw_data else {}
except (TypeError, ValueError):
data = raw_data
inferred = data.get("event") or data.get("type") if isinstance(data, Mapping) else ""
kind = event_name or str(inferred or "message")
if raw_data.strip() == "[DONE]":
kind, data = "done", {}
event = {"event": kind, "data": data}
if event_id:
event["id"] = event_id
event_name = ""
event_id = ""
data_lines = []
return event
for raw_line in lines:
if cancelled is not None and cancelled():
return
line = raw_line.lstrip("\ufeff")
if not line:
event = build_event()
if event is not None:
yield event
continue
if line.startswith(":"):
continue
field, separator, value = line.partition(":")
if separator and value.startswith(" "):
value = value[1:]
if field == "event":
event_name = value
elif field == "data":
data_lines.append(value)
elif field == "id":
event_id = value
if cancelled is None or not cancelled():
event = build_event()
if event is not None:
yield event
+137 -26
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import mimetypes
import re
import time
from collections.abc import Mapping, Sequence
from collections.abc import Callable, Iterator, Mapping, Sequence
from contextlib import suppress
from datetime import date
from io import BytesIO
@@ -13,11 +13,14 @@ from os import PathLike
from pathlib import Path
from typing import Any, Final, Literal, Protocol
from doctor_workstation.core.errors import (
ApiProtocolError,
ApiTransportError,
AuthenticationExpiredError,
)
from doctor_workstation.core.errors import (
ApiBusinessError,
ApiHttpError,
ApiProtocolError,
ApiTimeoutError,
ApiTransportError,
AuthenticationExpiredError,
)
from doctor_workstation.core.models import (
Appointment,
CallTicket,
@@ -191,6 +194,16 @@ class DoctorRepository(Protocol):
) -> dict[str, Any]:
"""Ask the first-party diagnosis assistant; the server selects the model."""
def stream_diagnosis_ai(
self,
diagnosis_id: int,
prompt: str,
*,
task: str = "custom",
cancelled: Callable[[], bool] | None = None,
) -> Iterator[dict[str, Any]]:
"""Yield normalized ``start``/``delta``/``done`` assistant events."""
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
@@ -1328,25 +1341,7 @@ class RemoteDoctorRepository:
) -> dict[str, Any]:
"""Submit a diagnosis question to the first-party server assistant."""
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
clean_prompt = prompt.strip()
if not clean_prompt:
raise ValueError("prompt is required")
if len(clean_prompt) > 500:
raise ValueError("prompt must not exceed 500 characters")
clean_task = task.strip().lower() or "custom"
if clean_task not in {
"summary",
"tcm_pattern",
"prescription_review",
"medication_review",
"exam_review",
"complication_risk",
"guideline_review",
"custom",
}:
raise ValueError("task is not supported")
clean_prompt, clean_task = _diagnosis_ai_request(diagnosis_id, prompt, task)
payload = _client_request(
self.client,
"post",
@@ -1359,6 +1354,63 @@ class RemoteDoctorRepository:
)
return dict(_require_mapping(payload, "tcm.diagnosis/aiAssistant"))
def stream_diagnosis_ai(
self,
diagnosis_id: int,
prompt: str,
*,
task: str = "custom",
cancelled: Callable[[], bool] | None = None,
) -> Iterator[dict[str, Any]]:
"""Stream a diagnosis answer, with one legacy fallback before first content."""
clean_prompt, clean_task = _diagnosis_ai_request(diagnosis_id, prompt, task)
body = {"id": diagnosis_id, "prompt": clean_prompt, "task": clean_task}
received_delta = False
received_done = False
try:
for raw_event in self.client.post_event_stream(
"tcm.diagnosis/aiAssistantStream",
body,
timeout=105.0,
cancelled=cancelled,
):
if cancelled is not None and cancelled():
return
event = _normalise_diagnosis_ai_event(raw_event)
if event is None:
continue
kind = event["event"]
if kind == "delta":
received_delta = True
elif kind == "done":
received_done = True
yield event
if kind == "done":
return
if cancelled is not None and cancelled():
return
if not received_done:
raise ApiProtocolError("AI assistant stream ended before done")
except (ApiHttpError, ApiProtocolError, ApiTimeoutError, ApiTransportError):
if received_delta or (cancelled is not None and cancelled()):
raise
# Older deployments do not expose the stream route. Submit exactly one
# request through the confirmed non-streaming endpoint in that case.
result = self.analyze_diagnosis_ai(
diagnosis_id,
clean_prompt,
task=clean_task,
)
if cancelled is not None and cancelled():
return
yield {"event": "start", "fallback": True}
answer = str(result.get("answer") or result.get("content") or "")
if answer:
yield {"event": "delta", "text": answer, "fallback": True}
yield {**result, "event": "done", "fallback": True}
def get_diagnosis_ai_analysis(
self,
diagnosis_id: int,
@@ -2638,7 +2690,66 @@ class RemoteDoctorRepository:
return self.update_prescription_template(template, changes, **fields)
def _page_params(page_no: int, page_size: int, filters: Mapping[str, Any]) -> dict[str, Any]:
def _diagnosis_ai_request(diagnosis_id: int, prompt: str, task: str) -> tuple[str, str]:
if diagnosis_id <= 0:
raise ValueError("diagnosis_id must be positive")
clean_prompt = prompt.strip()
if not clean_prompt:
raise ValueError("prompt is required")
if len(clean_prompt) > 500:
raise ValueError("prompt must not exceed 500 characters")
clean_task = task.strip().lower() or "custom"
if clean_task not in {
"summary",
"tcm_pattern",
"prescription_review",
"medication_review",
"exam_review",
"complication_risk",
"guideline_review",
"custom",
}:
raise ValueError("task is not supported")
return clean_prompt, clean_task
def _normalise_diagnosis_ai_event(raw: Mapping[str, Any]) -> dict[str, Any] | None:
kind = str(raw.get("event") or "message").strip().lower()
data = raw.get("data")
payload = dict(data) if isinstance(data, Mapping) else {}
if kind == "message":
kind = str(payload.get("event") or payload.get("type") or "message").lower()
if kind == "start":
payload.pop("event", None)
payload.pop("type", None)
return {**payload, "event": "start"}
if kind == "delta":
text = (
data
if isinstance(data, str)
else payload.get("delta")
or payload.get("content")
or payload.get("text")
or ""
)
if not isinstance(text, str):
raise ApiProtocolError("AI assistant delta content must be text", data=data)
return {"event": "delta", "text": text}
if kind == "done":
payload.pop("event", None)
payload.pop("type", None)
return {**payload, "event": "done"}
if kind == "error":
message = (
data
if isinstance(data, str)
else payload.get("message") or payload.get("msg") or payload.get("error")
)
raise ApiBusinessError(str(message or "AI assistant stream failed"), data=data)
return None
def _page_params(page_no: int, page_size: int, filters: Mapping[str, Any]) -> dict[str, Any]:
if page_no < 1 or page_size < 1:
raise ValueError("page_no and page_size must be positive")
result = {
@@ -2526,14 +2526,29 @@ class _RemoteImageButton(QPushButton):
request = QNetworkRequest(url)
request.setTransferTimeout(10_000)
request.setMaximumRedirectsAllowed(4)
request.setAttribute(
QNetworkRequest.Attribute.RedirectPolicyAttribute,
QNetworkRequest.RedirectPolicy.NoLessSafeRedirectPolicy,
)
reply = self._manager.get(request)
self._reply = reply
reply.setProperty("diagnosisImageGeneration", generation)
reply.finished.connect(self._reply_finished)
request.setAttribute(
QNetworkRequest.Attribute.RedirectPolicyAttribute,
QNetworkRequest.RedirectPolicy.SameOriginRedirectPolicy,
)
reply = self._manager.get(request)
self._reply = reply
reply.setProperty("diagnosisImageGeneration", generation)
reply.setProperty("diagnosisImageOversize", False)
reply.downloadProgress.connect(self._download_progress)
reply.finished.connect(self._reply_finished)
def _download_progress(self, bytes_received: int, bytes_total: int) -> None:
"""Abort a current reply as soon as its received or declared size is unsafe."""
reply = self.sender()
if reply is not self._reply:
return
if bytes_received <= self._MAX_IMAGE_BYTES and (
bytes_total < 0 or bytes_total <= self._MAX_IMAGE_BYTES
):
return
reply.setProperty("diagnosisImageOversize", True)
reply.abort()
def _reply_finished(self) -> None:
"""Use a QObject receiver connection so destruction auto-disconnects the callback."""
@@ -2553,16 +2568,19 @@ class _RemoteImageButton(QPushButton):
reply.deleteLater()
return
self._reply = None
if not self._owner_is_current():
reply.deleteLater()
return
error = reply.error()
payload = bytes(reply.readAll())
reply.deleteLater()
if error != QNetworkReply.NetworkError.NoError:
self._show_fallback()
return
self._apply_payload(payload, generation)
if not self._owner_is_current():
reply.deleteLater()
return
error = reply.error()
if bool(reply.property("diagnosisImageOversize")) or (
error != QNetworkReply.NetworkError.NoError
):
reply.deleteLater()
self._show_fallback()
return
payload = bytes(reply.readAll())
reply.deleteLater()
self._apply_payload(payload, generation)
def _apply_payload(self, payload: bytes, generation: int) -> bool:
"""Decode a current reply; kept separate so offline tests can exercise rendering."""
@@ -67,11 +67,33 @@ PRIMARY = QColor("#5265F6")
TEXT = QColor("#15224A")
SECONDARY = QColor("#7481A3")
PLACEHOLDER = QColor("#A4ADC3")
_INVALID_INDEX = QModelIndex()
_TABLE_COLUMN_WIDTHS = (48, 70, 60, 100, 175, 88, 120, 100, 72, 110, 120, 340)
def _menu_action_icon(kind: str, *, danger: bool = False) -> QIcon:
_INVALID_INDEX = QModelIndex()
_TABLE_COLUMN_WIDTHS = (48, 70, 60, 100, 175, 88, 120, 100, 72, 110, 120, 410)
def _render_signature(value: Any) -> Any:
"""Freeze repository DTOs into a stable, order-independent render key."""
if isinstance(value, Mapping):
items = ((str(key), _render_signature(item)) for key, item in value.items())
return ("mapping", tuple(sorted(items, key=lambda item: item[0])))
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return ("sequence", tuple(_render_signature(item) for item in value))
if isinstance(value, set | frozenset):
items = (_render_signature(item) for item in value)
return ("set", tuple(sorted(items, key=repr)))
try:
hash(value)
except TypeError:
return ("repr", repr(value))
return ("value", value)
def _rows_render_signature(rows: Sequence[Any]) -> tuple[Any, ...]:
return tuple(_render_signature(row) for row in rows)
def _menu_action_icon(kind: str, *, danger: bool = False) -> QIcon:
"""Draw the admin menu glyphs without relying on a symbol font."""
size = 18
@@ -450,12 +472,13 @@ class DiagnosisTableModel(QAbstractTableModel):
"操作",
)
def __init__(self, rows: Iterable[Any] = (), parent: QWidget | None = None) -> None:
super().__init__(parent)
self.rows: list[Any] = list(rows)
self._checked_ids: set[int] = set()
self._hover_row = -1
self._sort_direction = ""
def __init__(self, rows: Iterable[Any] = (), parent: QWidget | None = None) -> None:
super().__init__(parent)
self.rows: list[Any] = list(rows)
self._render_signature = _rows_render_signature(self.rows)
self._checked_ids: set[int] = set()
self._hover_row = -1
self._sort_direction = ""
def rowCount(self, parent: QModelIndex = _INVALID_INDEX) -> int: # noqa: N802
return 0 if parent.isValid() else len(self.rows)
@@ -526,15 +549,24 @@ class DiagnosisTableModel(QAbstractTableModel):
def record(self, row: int) -> Any:
return self.rows[row] if 0 <= row < len(self.rows) else None
def set_rows(self, rows: Iterable[Any]) -> None:
materialized = list(rows)
valid_ids = {self.record_id(row) for row in materialized}
self.beginResetModel()
self.rows = materialized
self._checked_ids.intersection_update(valid_ids)
self._hover_row = -1
self.endResetModel()
self.selection_changed.emit(len(self._checked_ids))
@property
def render_signature(self) -> tuple[Any, ...]:
return self._render_signature
def set_rows(self, rows: Iterable[Any]) -> bool:
materialized = list(rows)
render_signature = _rows_render_signature(materialized)
if render_signature == self._render_signature:
return False
valid_ids = {self.record_id(row) for row in materialized}
self.beginResetModel()
self.rows = materialized
self._render_signature = render_signature
self._checked_ids.intersection_update(valid_ids)
self._hover_row = -1
self.endResetModel()
self.selection_changed.emit(len(self._checked_ids))
return True
def checked_records(self) -> list[Any]:
return [row for row in self.rows if self.record_id(row) in self._checked_ids]
@@ -1157,11 +1189,12 @@ class DiagnosisTableView(QTableView):
def _current_row_changed(self, _current: QModelIndex, _previous: QModelIndex) -> None:
self.itemSelectionChanged.emit()
def set_rows(self, rows: Iterable[Any]) -> None:
self._diagnosis_model().set_rows(rows)
self.clearSelection()
self.setCurrentIndex(QModelIndex())
self.rows_replaced.emit()
def set_rows(self, rows: Iterable[Any]) -> None:
if not self._diagnosis_model().set_rows(rows):
return
self.clearSelection()
self.setCurrentIndex(QModelIndex())
self.rows_replaced.emit()
def rowCount(self) -> int: # noqa: N802 - compatibility
return self._diagnosis_model().rowCount()
@@ -1303,6 +1336,8 @@ class DiagnosisTableHost(QFrame):
) -> None:
super().__init__(parent)
self.setObjectName("DiagnosisTableHost")
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.setMinimumHeight(0)
self.action_policy = dict(action_policy or {})
self.force_open_prescription = False
self.model = DiagnosisTableModel(parent=self)
@@ -1316,10 +1351,13 @@ class DiagnosisTableHost(QFrame):
view.setModel(self.model)
view.setSelectionModel(self.selection)
view.setItemDelegate(self.delegate)
view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
view.setMinimumHeight(0)
view.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
view.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
view.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
view.horizontalHeader().setStretchLastSection(False)
self.main.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.fixed.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.main.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
self.fixed.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.main.setMinimumWidth(0)
@@ -1335,6 +1373,7 @@ class DiagnosisTableHost(QFrame):
layout.setSpacing(0)
layout.addWidget(self.main, 1)
layout.addWidget(self.fixed)
layout.setAlignment(self.fixed, Qt.AlignmentFlag.AlignTop)
self.fixed_shadow = _FixedColumnShadow(self)
self.fixed_shadow.show()
self.empty_label = QLabel("暂无数据", self)
@@ -1348,22 +1387,27 @@ class DiagnosisTableHost(QFrame):
self.main.hovered_row.connect(self._set_hover_row)
self.fixed.hovered_row.connect(self._set_hover_row)
self.model.selection_changed.connect(self._checked_changed)
self.main.horizontalHeader().sectionClicked.connect(self._header_clicked)
self._update_height()
def set_rows(self, rows: Iterable[Any]) -> None:
previous_id = DiagnosisTableModel.record_id(self.main.current_data())
self.model.set_rows(rows)
self.empty_label.setText("暂无数据")
self.empty_label.setProperty("stateKind", "empty")
self.empty_label.style().unpolish(self.empty_label)
self.empty_label.style().polish(self.empty_label)
self._rows_changed()
if previous_id > 0:
for index, record in enumerate(self.model.rows):
if DiagnosisTableModel.record_id(record) == previous_id:
self.main.selectRow(index)
break
self.main.horizontalHeader().sectionClicked.connect(self._header_clicked)
self.main.verticalScrollBar().valueChanged.connect(self.fixed.verticalScrollBar().setValue)
self.fixed.verticalScrollBar().valueChanged.connect(self.main.verticalScrollBar().setValue)
self.main.horizontalScrollBar().rangeChanged.connect(self._schedule_fixed_height_sync)
self._fixed_render_signature: tuple[Any, ...] | None = None
def set_rows(self, rows: Iterable[Any]) -> None:
previous_id = DiagnosisTableModel.record_id(self.main.current_data())
rows_changed = self.model.set_rows(rows)
self.empty_label.setText("暂无数据")
self.empty_label.setProperty("stateKind", "empty")
self.empty_label.style().unpolish(self.empty_label)
self.empty_label.style().polish(self.empty_label)
fixed_widgets_changed = self._fixed_widgets_signature() != self._fixed_render_signature
if rows_changed or fixed_widgets_changed:
self._rows_changed()
if rows_changed and previous_id > 0:
for index, record in enumerate(self.model.rows):
if DiagnosisTableModel.record_id(record) == previous_id:
self.main.selectRow(index)
break
def selected_records(self) -> list[Any]:
return self.model.checked_records()
@@ -1389,13 +1433,21 @@ class DiagnosisTableHost(QFrame):
def set_sort_direction(self, direction: str) -> None:
self.model.set_sort_direction(direction)
def _rows_changed(self) -> None:
self._sync_row_heights()
self._install_fixed_widgets()
self.empty_label.setVisible(self.model.rowCount() == 0)
self._update_height()
self._position_empty()
self._position_fixed_shadow()
def _rows_changed(self) -> None:
self._sync_row_heights()
self._install_fixed_widgets()
self._fixed_render_signature = self._fixed_widgets_signature()
self.empty_label.setVisible(self.model.rowCount() == 0)
self._position_empty()
self._position_fixed_shadow()
self._schedule_fixed_height_sync()
def _fixed_widgets_signature(self) -> tuple[Any, ...]:
return (
self.model.render_signature,
self.force_open_prescription,
tuple(sorted(self.action_policy.items())),
)
def _sync_row_heights(self) -> None:
for row, record in enumerate(self.model.rows):
@@ -1479,6 +1531,8 @@ class DiagnosisTableHost(QFrame):
"primary",
)
)
if self.action_policy.get("ai_consult", False):
layout.addWidget(self._action_button("AI 分析", "ai_consult", record, "primary"))
if self.action_policy.get("appointment", False):
layout.addWidget(self._action_button("预约", "appointment", record, "success"))
if self.action_policy.get("edit", False):
@@ -1604,14 +1658,23 @@ class DiagnosisTableHost(QFrame):
self.set_sort_direction(direction)
self.sort_unserved_requested.emit(direction)
def _update_height(self) -> None:
rows_height = sum(self.main.rowHeight(row) for row in range(self.model.rowCount()))
body_height = rows_height if rows_height else 60
horizontal = self.main.horizontalScrollBar().sizeHint().height()
self.setFixedHeight(39 + body_height + horizontal + 2)
def _schedule_fixed_height_sync(self, *_range: int) -> None:
"""Keep both table viewports equally tall when the main x-scrollbar appears."""
QTimer.singleShot(0, self._sync_fixed_height)
def _sync_fixed_height(self) -> None:
horizontal = self.main.horizontalScrollBar()
reserved = horizontal.sizeHint().height() if horizontal.maximum() > 0 else 0
target = max(0, self.height() - reserved)
if self.fixed.height() != target:
self.fixed.setFixedHeight(target)
def _position_empty(self) -> None:
self.empty_label.setGeometry(0, 39, self.width(), 60)
horizontal = self.main.horizontalScrollBar()
reserved = horizontal.sizeHint().height() if horizontal.maximum() > 0 else 0
body_height = max(0, self.height() - 39 - reserved)
self.empty_label.setGeometry(0, 39, self.width(), body_height)
self.empty_label.raise_()
def _position_fixed_shadow(self) -> None:
@@ -1626,6 +1689,7 @@ class DiagnosisTableHost(QFrame):
def resizeEvent(self, event: QResizeEvent) -> None:
super().resizeEvent(event)
self._sync_fixed_height()
self._position_empty()
self._position_fixed_shadow()
@@ -1639,14 +1703,16 @@ class DiagnosisPager(QWidget):
def __init__(self, page_size: int = 15, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("DiagnosisPager")
self.setFixedHeight(42)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.page = 1
self.page_size = page_size
self.total = 0
self._setting = False
self._page_buttons: list[QToolButton] = []
layout = QHBoxLayout(self)
layout.setContentsMargins(16, 10, 16, 16)
layout.setSpacing(8)
layout.setContentsMargins(12, 4, 12, 4)
layout.setSpacing(6)
layout.addStretch(1)
self.summary = QLabel("共 0 条")
self.summary.setProperty("pagerMuted", True)
@@ -1,5 +1,13 @@
"""Reusable doctor-workstation dialogs."""
from .ai_consult import AiConsultDialog, can_open_ai_consult, present_ai_consult
from .diagnosis import DiagnosisDialog, OrderDetailDrawer, present_order_detail
__all__ = ["DiagnosisDialog", "OrderDetailDrawer", "present_order_detail"]
__all__ = [
"AiConsultDialog",
"DiagnosisDialog",
"OrderDetailDrawer",
"can_open_ai_consult",
"present_ai_consult",
"present_order_detail",
]
File diff suppressed because it is too large Load Diff
@@ -5117,6 +5117,10 @@ class DiagnosisDetailDialog(QDialog):
source = _mapping(diagnosis)
self.repository = repository
self.permissions = permissions
self._order_detail_generation = 0
self._order_detail_order_id = 0
self._order_detail_table: QTableWidget | None = None
self._order_detail_button: QPushButton | None = None
self.setWindowTitle("诊单详情(只读)")
self.resize(880, 700)
root = QVBoxLayout(self)
@@ -5195,6 +5199,7 @@ class DiagnosisDetailDialog(QDialog):
table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
table.verticalHeader().hide()
table.horizontalHeader().setStretchLastSection(True)
self._order_detail_table = table
for row_index, row in enumerate(rows):
values = (
first_value(row, "order_no", "sn", "id"),
@@ -5212,36 +5217,78 @@ class DiagnosisDetailDialog(QDialog):
actions = QHBoxLayout()
view = QPushButton("查看订单详情")
view.setProperty("variant", "primary")
def open_selected() -> None:
row = table.currentRow()
item = table.item(row, 0) if row >= 0 else None
order = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
if order is None:
return
order_id = _int(first_value(order, "id", "order_id"), 0)
if order_id > 0 and callable(getattr(self.repository, "get_prescription_order", None)):
try: # noqa: SIM105 - retain the embedded row if detail lookup fails
order = self.repository.get_prescription_order(order_id)
except Exception: # noqa: BLE001 - fall back to embedded row
pass
from .diagnosis import present_order_detail
present_order_detail(
self.window() if self.window() is not None else self,
order,
order_id=order_id,
permissions=self.permissions,
exec_=True,
)
view.clicked.connect(open_selected)
table.itemDoubleClicked.connect(lambda _item: open_selected())
self._order_detail_button = view
view.clicked.connect(self._open_selected_order)
table.itemDoubleClicked.connect(lambda _item: self._open_selected_order())
actions.addWidget(view)
actions.addStretch(1)
layout.addLayout(actions)
return host
def _set_order_detail_loading(self, loading: bool) -> None:
if self._order_detail_table is not None:
self._order_detail_table.setEnabled(not loading)
if self._order_detail_button is not None:
self._order_detail_button.setEnabled(not loading)
def _open_selected_order(self) -> None:
table = self._order_detail_table
if table is None:
return
row = table.currentRow()
item = table.item(row, 0) if row >= 0 else None
order = item.data(Qt.ItemDataRole.UserRole) if item is not None else None
if order is None:
return
order_id = _int(first_value(order, "id", "order_id"), 0)
self._order_detail_generation += 1
generation = self._order_detail_generation
self._order_detail_order_id = order_id
getter = getattr(self.repository, "get_prescription_order", None)
if order_id <= 0 or not callable(getter):
self._set_order_detail_loading(False)
self._present_order_detail(order, order_id)
return
self._set_order_detail_loading(True)
run_async(
lambda: getter(order_id),
on_success=lambda result: self._order_detail_success(result, order_id, generation),
on_error=lambda error: self._order_detail_error(error, order, order_id, generation),
on_finished=lambda: self._order_detail_finished(order_id, generation),
)
def _order_detail_success(self, order: Any, order_id: int, generation: int) -> None:
if generation != self._order_detail_generation or order_id != self._order_detail_order_id:
return
self._present_order_detail(order, order_id)
def _order_detail_error(
self,
_error: Exception,
fallback_order: Any,
order_id: int,
generation: int,
) -> None:
if generation != self._order_detail_generation or order_id != self._order_detail_order_id:
return
self._present_order_detail(fallback_order, order_id)
def _order_detail_finished(self, order_id: int, generation: int) -> None:
if generation == self._order_detail_generation and order_id == self._order_detail_order_id:
self._set_order_detail_loading(False)
def _present_order_detail(self, order: Any, order_id: int) -> None:
from .diagnosis import present_order_detail
present_order_detail(
self.window() if self.window() is not None else self,
order,
order_id=order_id,
permissions=self.permissions,
exec_=True,
)
class PrescriptionOrderDialog(QDialog):
"""Create a fulfilment order from one issued prescription."""
@@ -12,6 +12,7 @@ from typing import Any
from PySide6.QtCore import QDate, QSize, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QAction, QBrush, QColor, QDesktopServices, QPixmap
from PySide6.QtWidgets import (
QAbstractItemView,
QButtonGroup,
QCheckBox,
QComboBox,
@@ -34,6 +35,7 @@ from PySide6.QtWidgets import (
)
from ..dialogs import DiagnosisDialog
from ..dialogs.ai_consult import can_open_ai_consult, present_ai_consult
from ..dialogs.prescription import (
PrescriptionDetailDialog,
PrescriptionEditorDialog,
@@ -92,12 +94,12 @@ _SEMANTIC_COLORS = {
APPOINTMENTS_LIGHT_QSS = """
#AppointmentsPage QWidget#PageHeader {
min-height: 42px;
max-height: 42px;
min-height: 26px;
max-height: 26px;
}
#AppointmentsPage QFrame#AppointmentFilterPanel {
min-height: 92px;
max-height: 92px;
min-height: 80px;
max-height: 80px;
background-color: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 10px;
@@ -109,9 +111,9 @@ APPOINTMENTS_LIGHT_QSS = """
border-radius: 10px;
}
#AppointmentsPage QPushButton[appointmentStat="true"] {
min-height: 34px;
max-height: 34px;
padding: 0 10px;
min-height: 30px;
max-height: 30px;
padding: 0 7px;
color: #59698E;
background-color: #F8F9FD;
border: 0;
@@ -141,8 +143,8 @@ APPOINTMENTS_LIGHT_QSS = """
background-color: #FFF8ED;
}
#AppointmentsPage QLineEdit#AppointmentPatientSearch {
min-height: 34px;
max-height: 34px;
min-height: 30px;
max-height: 30px;
background-color: #FFFFFF;
border: 1px solid #DDE3F0;
border-radius: 7px;
@@ -157,10 +159,10 @@ APPOINTMENTS_LIGHT_QSS = """
padding: 0 2px;
}
#AppointmentsPage QTableWidget#AppointmentTable::item {
padding: 6px 8px;
padding: 5px 7px;
}
#AppointmentsPage QTableWidget#AppointmentTable QHeaderView::section {
min-height: 35px;
min-height: 34px;
background-color: #F8FAFF;
}
#AppointmentsPage QTableWidget#AppointmentTable {
@@ -211,8 +213,9 @@ APPOINTMENTS_LIGHT_QSS = """
background-color: #FFFFFF;
}
#AppointmentsPage QPushButton[compactAction="true"] {
min-height: 30px;
padding: 0 10px;
min-height: 28px;
max-height: 28px;
padding: 0 8px;
border-radius: 7px;
font-size: 11px;
}
@@ -238,7 +241,7 @@ APPOINTMENTS_LIGHT_QSS = """
#AppointmentsPage QPushButton[variant="chip"]:focus { border-color: #8D9BFF; }
#AppointmentsPage QTabBar#AppointmentStatusTabs::tab {
min-width: 62px;
min-height: 32px;
min-height: 28px;
padding: 0 7px;
color: #7481A3;
background-color: transparent;
@@ -326,9 +329,9 @@ APPOINTMENTS_LIGHT_QSS = """
#AppointmentsPage QPushButton[videoActionKind="success"] { color: #159C79; }
#AppointmentsPage QPushButton[videoActionKind="warning"] { color: #C17A16; }
#AppointmentsPage QPushButton[filterChoice="true"] {
min-height: 32px;
max-height: 32px;
padding: 0 13px;
min-height: 28px;
max-height: 28px;
padding: 0 10px;
color: #405074;
background-color: transparent;
border: 1px solid transparent;
@@ -436,14 +439,9 @@ def _diagnosis_id(row: Any) -> int:
def _video_patient_id(row: Any) -> int:
source = _as_int(first_value(row, "source_patient_id", default=0))
if source > 0:
return source
diagnosis = _as_int(first_value(row, "diagnosis_id", default=0))
patient = _as_int(first_value(row, "patient_id", default=0))
if diagnosis > 0 and patient > 0 and diagnosis != patient:
return patient
return patient
"""Return only the appointment row's explicitly separated patient owner."""
return _as_int(first_value(row, "source_patient_id", default=0))
def _appointment_id(row: Any) -> int:
@@ -604,6 +602,56 @@ def _flatten_departments(
return result
def _stable_signature_value(value: Any) -> Any:
"""Return a deterministic, order-independent representation of API data."""
if value is None or isinstance(value, (bool, int, str)):
return value
if isinstance(value, float):
return repr(value)
if isinstance(value, Mapping):
items = (
(str(key), _stable_signature_value(item))
for key, item in value.items()
)
return tuple(sorted(items, key=lambda pair: pair[0]))
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return tuple(_stable_signature_value(item) for item in value)
if isinstance(value, (set, frozenset)):
frozen = (_stable_signature_value(item) for item in value)
return tuple(sorted(frozen, key=repr))
dataclass_fields = getattr(type(value), "__dataclass_fields__", None)
if isinstance(dataclass_fields, Mapping):
return (
type(value).__qualname__,
tuple(
(name, _stable_signature_value(getattr(value, name, None)))
for name in dataclass_fields
),
)
state = getattr(value, "__dict__", None)
if isinstance(state, Mapping):
return (type(value).__qualname__, _stable_signature_value(state))
isoformat = getattr(value, "isoformat", None)
if callable(isoformat):
try:
return (type(value).__qualname__, isoformat())
except (TypeError, ValueError):
pass
return (type(value).__qualname__, str(value))
def _appointment_result_signature(rows: Sequence[Any], total: int, extend: Any) -> tuple[Any, ...]:
"""Build the stable render signature used to short-circuit unchanged polling."""
return (
"appointments-render-v1",
total,
_stable_signature_value(rows),
_stable_signature_value(extend),
)
class AppointmentsPage(QWidget):
"""Desktop appointment list with status tabs, filters, call and prescription."""
@@ -639,6 +687,9 @@ class AppointmentsPage(QWidget):
self._end_date = self._start_date
self._status_counts: dict[int, int] = {1: 0, 2: 0, 3: 0, 4: 0}
self._dict_labels: dict[str, dict[str, str]] = {}
self._last_render_signature: tuple[Any, ...] | None = None
self._responsive_narrow: bool | None = None
self._video_panel_user_open = False
self._can_plain_phone = _canonical_allowed(
permissions, "tcm.diagnosis/phonePlain", default=False
)
@@ -646,20 +697,23 @@ class AppointmentsPage(QWidget):
self._is_admin = _is_admin_user(current_user)
root = QVBoxLayout(self)
root.setContentsMargins(22, 7, 6, 8)
root.setContentsMargins(18, 3, 6, 8)
root.setSpacing(4)
header = PageHeader("问诊列表")
header.title_label.hide()
header.subtitle_label.hide()
root.addWidget(header)
root.addWidget(self._build_filter_panel())
self.header = PageHeader("问诊列表")
self.header.title_label.hide()
self.header.subtitle_label.hide()
root.addWidget(self.header)
self.filter_panel = self._build_filter_panel()
root.addWidget(self.filter_panel)
self.banner = MessageBanner()
root.addWidget(self.banner)
root.addWidget(self._build_content(), 1)
self.content_host = self._build_content()
root.addWidget(self.content_host, 1)
self.poll_timer = QTimer(self)
self.poll_timer.setInterval(LIST_POLL_MS)
self.poll_timer.timeout.connect(lambda: self.refresh(silent=True))
self._apply_responsive_layout()
def _diagnosis_dialog(self) -> DiagnosisDialog:
dialog = getattr(self, "_diagnosis_dialog_impl", None)
@@ -673,76 +727,77 @@ class AppointmentsPage(QWidget):
frame = QFrame()
frame.setObjectName("AppointmentFilterPanel")
layout = QVBoxLayout(frame)
layout.setContentsMargins(4, 2, 12, 12)
layout.setSpacing(10)
layout.setContentsMargins(6, 4, 8, 6)
layout.setSpacing(4)
date_row = QHBoxLayout()
date_row.setSpacing(8)
date_row.setSpacing(4)
self.date_buttons: dict[str, QPushButton] = {}
self._date_stat_labels: dict[str, str] = {}
stat_widths = {
"yesterday": 106,
"day_before": 98,
"today": 98,
"tomorrow": 94,
"day_after": 94,
"": 110,
}
for source_label, preset in DATE_PRESETS:
label = "全部" if preset == "" else source_label
button = QPushButton(f"{label} 0")
button.setCheckable(True)
button.setProperty("appointmentStat", True)
button.setFixedWidth(stat_widths[preset])
button.setMinimumWidth(0)
button.clicked.connect(
lambda _checked=False, value=preset: self._set_date_preset(value)
)
self.date_buttons[preset] = button
self._date_stat_labels[preset] = label
date_row.addWidget(button)
date_row.addWidget(button, 1)
self.date_buttons["today"].setChecked(True)
self.date_overflow_button = QPushButton("日期")
self.date_overflow_button.setProperty("appointmentStat", True)
date_menu = QMenu(self.date_overflow_button)
for source_label, preset in DATE_PRESETS:
action = date_menu.addAction(source_label if preset else "不限日期")
action.triggered.connect(
lambda _checked=False, value=preset: self._set_date_preset(value)
)
date_menu.addSeparator()
date_menu.addAction("自定义日期").triggered.connect(self._open_custom_date)
self.date_overflow_button.setMenu(date_menu)
date_row.addWidget(self.date_overflow_button)
self.pending_stat_button = QPushButton("待预约 0")
self.pending_stat_button.setProperty("appointmentStat", True)
self.pending_stat_button.setProperty("appointmentStatKind", "pending")
self.pending_stat_button.setFixedWidth(104)
self.pending_stat_button.setMinimumWidth(0)
self.pending_stat_button.clicked.connect(lambda: self._set_status_from_stat(1))
date_row.addWidget(self.pending_stat_button)
date_row.addWidget(self.pending_stat_button, 1)
self.completed_stat_button = QPushButton("已完成 0")
self.completed_stat_button.setProperty("appointmentStat", True)
self.completed_stat_button.setProperty("appointmentStatKind", "success")
self.completed_stat_button.setFixedWidth(104)
self.completed_stat_button.setMinimumWidth(0)
self.completed_stat_button.clicked.connect(lambda: self._set_status_from_stat(3))
date_row.addWidget(self.completed_stat_button)
date_row.addWidget(self.completed_stat_button, 1)
self.unassigned_stat_button = QPushButton("待分配医助 0")
self.unassigned_stat_button.setCheckable(True)
self.unassigned_stat_button.setProperty("appointmentStat", True)
self.unassigned_stat_button.setProperty("appointmentStatKind", "warning")
self.unassigned_stat_button.setFixedWidth(130)
self.unassigned_stat_button.setMinimumWidth(0)
self.unassigned_stat_button.clicked.connect(self._toggle_unassigned_filter)
date_row.addWidget(self.unassigned_stat_button)
date_row.addStretch(1)
date_row.addWidget(self.unassigned_stat_button, 1)
self.patient_input = QLineEdit()
self.patient_input.setObjectName("AppointmentPatientSearch")
self.patient_input.setPlaceholderText("患者姓名 / 手机号")
self.patient_input.setClearButtonEnabled(True)
self.patient_input.setFixedWidth(204)
self.patient_input.returnPressed.connect(self._search)
date_row.addWidget(self.patient_input)
date_row.addWidget(self.patient_input, 2)
search = QPushButton("查询")
search.setProperty("variant", "primary")
search.setFixedWidth(62)
search.clicked.connect(self._search)
date_row.addWidget(search)
layout.addLayout(date_row)
filter_row = QHBoxLayout()
filter_row.setSpacing(7)
status_label = QLabel("挂号状态:")
status_label.setObjectName("FilterRowLabel")
filter_row.addWidget(status_label)
filter_row.setSpacing(5)
self.status_filter_label = QLabel("挂号状态:")
self.status_filter_label.setObjectName("FilterRowLabel")
filter_row.addWidget(self.status_filter_label)
self.tab_bar = QTabBar()
self.tab_bar.setObjectName("AppointmentStatusTabs")
self._tab_indexes: dict[str | int, int] = {}
@@ -753,13 +808,15 @@ class AppointmentsPage(QWidget):
self.tab_bar.currentChanged.connect(self._tab_changed)
filter_row.addWidget(self.tab_bar)
self.filter_dividers: list[QLabel] = []
divider = QLabel("│")
divider.setObjectName("FilterDivider")
self.filter_dividers.append(divider)
filter_row.addWidget(divider)
confirm_label = QLabel("确认状态:")
confirm_label.setObjectName("FilterRowLabel")
filter_row.addWidget(confirm_label)
self.confirm_filter_label = QLabel("确认状态:")
self.confirm_filter_label.setObjectName("FilterRowLabel")
filter_row.addWidget(self.confirm_filter_label)
self.confirmed_filter = QComboBox(frame)
self.confirmed_filter.addItem("全部", "")
self.confirmed_filter.addItem("已确认", "1")
@@ -783,6 +840,7 @@ class AppointmentsPage(QWidget):
divider = QLabel("│")
divider.setObjectName("FilterDivider")
self.filter_dividers.append(divider)
filter_row.addWidget(divider)
self.more_filters_button = QPushButton("更多筛选")
@@ -825,26 +883,32 @@ class AppointmentsPage(QWidget):
def _build_content(self) -> QWidget:
host = QWidget()
host.setMinimumHeight(0)
layout = QHBoxLayout(host)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
layout.addWidget(self._build_table_card(), 3)
layout.addWidget(self._build_video_panel(), 1)
self.content_layout = layout
self.table_card = self._build_table_card()
self.video_panel = self._build_video_panel()
layout.addWidget(self.table_card, 3)
layout.addWidget(self.video_panel, 1)
return host
def _build_video_panel(self) -> QWidget:
panel = QFrame()
panel.setObjectName("VideoConsultPanel")
panel.setFixedWidth(420)
panel.setMinimumWidth(0)
panel.setMaximumWidth(420)
layout = QVBoxLayout(panel)
layout.setContentsMargins(18, 17, 18, 10)
layout.setSpacing(10)
layout.setContentsMargins(14, 12, 14, 8)
layout.setSpacing(8)
title = QLabel("视频问诊")
title.setObjectName("VideoConsultTitle")
layout.addWidget(title)
self.video_list = QListWidget()
self.video_list.setObjectName("VideoConsultList")
self.video_list.setSpacing(0)
self.video_list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.video_list.currentRowChanged.connect(self._video_row_selected)
self.video_list.itemDoubleClicked.connect(lambda _item: self._open_detail())
layout.addWidget(self.video_list, 1)
@@ -857,9 +921,10 @@ class AppointmentsPage(QWidget):
def _build_table_card(self) -> QWidget:
card = QFrame()
card.setObjectName("AppointmentMainCard")
card.setMinimumHeight(0)
layout = QVBoxLayout(card)
layout.setContentsMargins(4, 18, 10, 10)
layout.setSpacing(14)
layout.setContentsMargins(4, 8, 8, 8)
layout.setSpacing(8)
compatibility_host = QWidget(card)
compatibility_host.hide()
@@ -904,7 +969,7 @@ class AppointmentsPage(QWidget):
widget.setProperty("compactAction", True)
actions = QHBoxLayout()
actions.setSpacing(9)
actions.setSpacing(6)
self.toolbar_edit_button = QPushButton("编辑患者", card)
self.toolbar_edit_button.setProperty("variant", "primary")
self.toolbar_edit_button.setProperty("compactAction", True)
@@ -923,6 +988,13 @@ class AppointmentsPage(QWidget):
self.toolbar_qr_button.setEnabled(False)
self.toolbar_qr_button.clicked.connect(self._request_video_qr)
actions.addWidget(self.toolbar_qr_button)
self.toolbar_ai_consult_button = QPushButton("AI 分析", card)
self.toolbar_ai_consult_button.setProperty("variant", "secondary")
self.toolbar_ai_consult_button.setProperty("compactAction", True)
self.toolbar_ai_consult_button.setVisible(can_open_ai_consult(self.permissions))
self.toolbar_ai_consult_button.setEnabled(False)
self.toolbar_ai_consult_button.clicked.connect(self._open_ai_consult)
actions.addWidget(self.toolbar_ai_consult_button)
self.toolbar_cancel_button = QPushButton("取消挂号", card)
self.toolbar_cancel_button.setProperty("variant", "danger")
self.toolbar_cancel_button.setProperty("compactAction", True)
@@ -932,9 +1004,16 @@ class AppointmentsPage(QWidget):
self.toolbar_cancel_button.setEnabled(False)
self.toolbar_cancel_button.clicked.connect(self._cancel_selected)
actions.addWidget(self.toolbar_cancel_button)
self.video_panel_button = QPushButton("视频问诊", card)
self.video_panel_button.setCheckable(True)
self.video_panel_button.setProperty("variant", "ghost")
self.video_panel_button.setProperty("compactAction", True)
self.video_panel_button.clicked.connect(self._toggle_video_panel)
actions.addWidget(self.video_panel_button)
actions.addStretch(1)
refresh = QPushButton("刷新", card)
refresh.setProperty("variant", "ghost")
refresh.setProperty("compactAction", True)
refresh.clicked.connect(lambda: self.refresh())
actions.addWidget(refresh)
layout.addLayout(actions)
@@ -958,17 +1037,56 @@ class AppointmentsPage(QWidget):
]
)
self.table.setObjectName("AppointmentTable")
self.table.setMinimumHeight(0)
self.table.setWordWrap(True)
self.table.horizontalHeader().setFixedHeight(38)
self.table.verticalHeader().setDefaultSectionSize(66)
self.table.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.table.horizontalHeader().setFixedHeight(34)
self.table.verticalHeader().setDefaultSectionSize(60)
self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._open_detail())
layout.addWidget(self.table, 1)
self.pager = Pager(self._page_size)
self.pager.setMaximumHeight(38)
self.pager.page_changed.connect(self._page_changed)
layout.addWidget(self.pager)
return card
def _toggle_video_panel(self, checked: bool) -> None:
self._video_panel_user_open = checked
self._apply_responsive_layout()
def _apply_responsive_layout(self) -> None:
"""Keep the table usable while retaining a video-list entry at low widths."""
panel = getattr(self, "video_panel", None)
button = getattr(self, "video_panel_button", None)
if not isinstance(panel, QWidget) or not isinstance(button, QPushButton):
return
narrow = self.width() < 1120
for preset in ("yesterday", "day_before", "tomorrow", "day_after"):
self.date_buttons[preset].setVisible(not narrow)
self.date_overflow_button.setVisible(narrow)
self.status_filter_label.setVisible(not narrow)
self.confirm_filter_label.setVisible(not narrow)
if narrow:
if self._responsive_narrow is not True:
self._video_panel_user_open = False
button.show()
button.setChecked(self._video_panel_user_open)
panel.setVisible(self._video_panel_user_open)
if self._video_panel_user_open:
panel.setFixedWidth(max(250, min(300, round(self.width() * 0.32))))
else:
button.hide()
button.setChecked(False)
panel.show()
panel.setFixedWidth(max(300, min(420, round(self.width() * 0.30))))
self._responsive_narrow = narrow
def resizeEvent(self, event: Any) -> None:
super().resizeEvent(event)
self._apply_responsive_layout()
def _sync_video_list(self, rows: Sequence[Any]) -> None:
selected_id = (
_appointment_id(self.video_list.currentItem().data(Qt.ItemDataRole.UserRole))
@@ -1002,6 +1120,7 @@ class AppointmentsPage(QWidget):
self.video_list.setCurrentRow(row_to_select)
else:
self.video_list.setCurrentRow(-1)
self.video_panel_button.setText(f"视频问诊 {len(rows)}")
def _build_video_patient_card(self, row: Any) -> QWidget:
card = QFrame()
@@ -1066,6 +1185,8 @@ class AppointmentsPage(QWidget):
)
add_action("查看", self._open_detail)
add_action("诊单", self._edit_patient, "tcm.diagnosis/edit")
if can_open_ai_consult(self.permissions):
add_action("AI 分析", self._open_ai_consult)
add_action("开方", self._open_prescription, "tcm.diagnosis/kaifang")
add_action("二维码", self._request_video_qr, "tcm.diagnosis/videoQr", "success")
more = QPushButton("更多", card)
@@ -1109,6 +1230,8 @@ class AppointmentsPage(QWidget):
add_action("完成问诊", self._complete_selected, "doctor.appointment/complete")
add_action("查看病历", self._view_case, "tcm.diagnosis/kaifang")
if can_open_ai_consult(self.permissions):
add_action("AI 分析", self._open_ai_consult)
if can_open_diagnosis_ai_report(self.permissions):
add_action("AI 报告", self._open_ai_report)
add_action("取消挂号", self._cancel_selected, "doctor.appointment/cancel")
@@ -1193,6 +1316,13 @@ class AppointmentsPage(QWidget):
self._date_preset = preset
for key, button in self.date_buttons.items():
button.setChecked(key == preset)
overflow_labels = {
"yesterday": "昨天",
"day_before": "前天",
"tomorrow": "明天",
"day_after": "后天",
}
self.date_overflow_button.setText(overflow_labels.get(preset, "日期"))
target = _offset_date(preset)
if target is None:
self._start_date = ""
@@ -1248,6 +1378,7 @@ class AppointmentsPage(QWidget):
for button in self.date_buttons.values():
button.setChecked(False)
self.date_buttons[""].setChecked(True)
self.date_overflow_button.setText("自定义")
self._start_date = start_date
self._end_date = end_date
self._page = 1
@@ -1332,6 +1463,16 @@ class AppointmentsPage(QWidget):
rows = page_items(result)
total = page_total(result)
extend = get_value(result, "extend", {}) or {}
signature = (
self._page,
_stable_signature_value(self._query_filters()),
*_appointment_result_signature(rows, total, extend),
)
if signature == self._last_render_signature:
if not silent:
self.banner.clear()
return
self._last_render_signature = signature
status_count = get_value(extend, "status_count", {}) or {}
if isinstance(status_count, Mapping):
for key in (1, 2, 3, 4):
@@ -1507,7 +1648,7 @@ class AppointmentsPage(QWidget):
default=1,
)
height = line_count * line_height + TABLE_CELL_VERTICAL_PADDING
self.table.setRowHeight(row_index, max(66, height))
self.table.setRowHeight(row_index, max(60, min(66, height)))
def _load_error(self, error: Exception, generation: int, silent: bool) -> None:
if generation != self._generation:
@@ -1551,6 +1692,9 @@ class AppointmentsPage(QWidget):
self.prescription_button.setText(prescription_action_label(row) if has_row else "开方")
self.case_button.setEnabled(has_row and _appointment_id(row) > 0)
self.ai_button.setEnabled(has_row and _diagnosis_id(row) > 0)
self.toolbar_ai_consult_button.setEnabled(
has_row and can_open_ai_consult(self.permissions) and _diagnosis_id(row) > 0
)
self.cancel_button.setEnabled(has_row and status == 1)
self.toolbar_edit_button.setEnabled(has_row and _diagnosis_id(row) > 0)
self.toolbar_qr_button.setEnabled(
@@ -1592,6 +1736,19 @@ class AppointmentsPage(QWidget):
)
present_diagnosis_ai_report(self.repository, self.permissions, self, payload)
def _open_ai_consult(self) -> None:
row = self._current_row()
diagnosis_id = _diagnosis_id(row)
present_ai_consult(
self.repository,
self.permissions,
self,
diagnosis_id=diagnosis_id,
patient_id=_video_patient_id(row),
seed=row,
source_title="问诊列表",
)
def _request_video(self) -> None:
if not _canonical_allowed(
self.permissions, "doctor.appointment/prescription", default=False
@@ -1640,7 +1797,7 @@ class AppointmentsPage(QWidget):
if row is None or _status_value(row) == 3:
return
diagnosis_id = _diagnosis_id(row)
patient_id = _video_patient_id(row) or _as_int(first_value(row, "patient_id", default=0))
patient_id = _video_patient_id(row)
doctor_id = _as_int(first_value(row, "doctor_id", default=0))
share_user_id = _as_int(first_value(self.current_user, "id", default=0))
if diagnosis_id <= 0 or patient_id <= 0 or doctor_id <= 0 or share_user_id <= 0:
@@ -29,6 +29,7 @@ from PySide6.QtWidgets import (
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QTableWidget,
QTableWidgetItem,
@@ -46,6 +47,7 @@ from ..diagnosis_index_widgets import (
FlowWidget,
)
from ..dialogs import DiagnosisDialog
from ..dialogs.ai_consult import can_open_ai_consult, present_ai_consult
from ..dialogs.prescription import (
PrescriptionDetailDialog,
PrescriptionEditorDialog,
@@ -68,8 +70,12 @@ from ..widgets import (
show_toast,
)
_PAGE_HEADER_HEIGHT = 62
_STATUS_CARD_HEIGHT = 50
_FILTERS_COLLAPSED_HEIGHT = 90
CONSULTATIONS_REFERENCE_QSS = """
#DiagnosisIndex QWidget#PageHeader { min-height: 72px; }
#DiagnosisIndex QWidget#PageHeader { min-height: 62px; max-height: 62px; }
#DiagnosisIndex QLabel[role="pageTitle"] {
color: #15224A; font-size: 22px; font-weight: 700;
}
@@ -82,7 +88,7 @@ CONSULTATIONS_REFERENCE_QSS = """
background: #FFFFFF; border: 1px solid #E2E7F4; border-radius: 13px;
}
#DiagnosisIndex QFrame#DiagnosisStatusCard {
min-height: 60px; max-height: 60px;
min-height: 50px; max-height: 50px;
}
#DiagnosisIndex QFrame#DiagnosisStatusCard QToolButton[diagnosisChip="true"] {
min-height: 34px; max-height: 34px; min-width: 56px;
@@ -103,7 +109,7 @@ CONSULTATIONS_REFERENCE_QSS = """
min-width: 116px; max-width: 116px;
}
#DiagnosisIndex QFrame#DiagnosisFilterCard {
min-height: 106px;
min-height: 88px;
}
#DiagnosisIndex QWidget#DiagnosisDateFilters,
#DiagnosisIndex QWidget#DiagnosisSecondaryFilters {
@@ -147,7 +153,7 @@ CONSULTATIONS_REFERENCE_QSS = """
border-radius: 8px; font-size: 12px;
}
#DiagnosisIndex QFrame#DiagnosisListToolbar {
min-height: 54px; max-height: 54px; background: #FFFFFF;
min-height: 44px; max-height: 44px; background: #FFFFFF;
border-bottom: 1px solid #E7EBF5;
}
#DiagnosisIndex QFrame#DiagnosisListToolbar QPushButton {
@@ -167,7 +173,7 @@ CONSULTATIONS_REFERENCE_QSS = """
}
#DiagnosisIndex QTableView { background: #FFFFFF; alternate-background-color: #FBFCFF; }
#DiagnosisIndex QToolButton[rowLink] { font-size: 11px; padding: 2px; }
#DiagnosisIndex QWidget#DiagnosisPager { min-height: 48px; max-height: 48px; }
#DiagnosisIndex QWidget#DiagnosisPager { min-height: 42px; max-height: 42px; }
#DiagnosisIndex QToolButton[pagerButton="true"] {
min-width: 32px; min-height: 32px; max-height: 32px;
border: 1px solid #E2E7F4; border-radius: 7px; background: #FFFFFF;
@@ -1068,30 +1074,31 @@ class ConsultationsPage(QWidget):
self.page_scroll.setWidgetResizable(True)
self.page_scroll.setFrameShape(QFrame.Shape.NoFrame)
self.page_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.page_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
root.addWidget(self.page_scroll)
content = QWidget()
content.setObjectName("DiagnosisIndexContent")
content.setAutoFillBackground(False)
self.page_scroll.setWidget(content)
page_layout = QVBoxLayout(content)
page_layout.setContentsMargins(20, 18, 29, 16)
page_layout.setSpacing(12)
page_layout.setContentsMargins(18, 10, 18, 10)
page_layout.setSpacing(8)
page_layout.addWidget(
PageHeader(
"问诊列表",
"按状态与日期管理患者队列,完成通话、开方与接诊闭环。",
content,
)
self.page_header = PageHeader(
"问诊列表",
"按状态与日期管理患者队列,完成通话、开方与接诊闭环。",
content,
)
self.page_header.setFixedHeight(_PAGE_HEADER_HEIGHT)
page_layout.addWidget(self.page_header)
status_card = QFrame()
status_card.setObjectName("DiagnosisStatusCard")
status_card.setFixedHeight(62)
status_card.setFixedHeight(_STATUS_CARD_HEIGHT)
self.status_card = status_card
status_card_layout = QHBoxLayout(status_card)
status_card_layout.setContentsMargins(12, 7, 12, 7)
status_card_layout.setSpacing(12)
status_card_layout.setContentsMargins(12, 5, 12, 5)
status_card_layout.setSpacing(10)
status_tabs = QWidget(status_card)
status_tabs.setObjectName("DiagnosisStatusTabs")
@@ -1160,8 +1167,8 @@ class ConsultationsPage(QWidget):
filters.setObjectName("DiagnosisFilterCard")
self.filters_card = filters
filter_layout = QVBoxLayout(filters)
filter_layout.setContentsMargins(14, 10, 14, 10)
filter_layout.setSpacing(7)
filter_layout.setContentsMargins(12, 6, 12, 6)
filter_layout.setSpacing(4)
date_filters = QWidget(filters)
date_filters.setObjectName("DiagnosisDateFilters")
@@ -1365,11 +1372,12 @@ class ConsultationsPage(QWidget):
advanced_layout.addWidget(self.advanced_filter_flow)
self.advanced_filters.hide()
filter_layout.addWidget(self.advanced_filters)
filters.setFixedHeight(108)
filters.setFixedHeight(_FILTERS_COLLAPSED_HEIGHT)
page_layout.addWidget(filters)
card = QFrame()
card.setObjectName("DiagnosisListCard")
card.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.list_card = card
card_layout = QVBoxLayout(card)
card_layout.setContentsMargins(0, 0, 0, 0)
@@ -1377,8 +1385,8 @@ class ConsultationsPage(QWidget):
toolbar = QFrame()
toolbar.setObjectName("DiagnosisListToolbar")
toolbar_layout = QHBoxLayout(toolbar)
toolbar_layout.setContentsMargins(14, 6, 14, 6)
toolbar_layout.setSpacing(8)
toolbar_layout.setContentsMargins(12, 4, 12, 4)
toolbar_layout.setSpacing(6)
self.add_button = QPushButton("+ 新增患者", toolbar)
self.add_button.setProperty("variant", "primary")
self.add_button.setVisible(_canonical_allowed(permissions, "tcm.diagnosis/add"))
@@ -1479,14 +1487,16 @@ class ConsultationsPage(QWidget):
toolbar_layout.addWidget(self.refresh_button)
table_wrap = QWidget()
table_wrap.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
table_wrap_layout = QVBoxLayout(table_wrap)
table_wrap_layout.setContentsMargins(12, 8, 12, 0)
table_wrap_layout.setContentsMargins(12, 4, 12, 0)
table_wrap_layout.setSpacing(0)
self.table_host = DiagnosisTableHost(
action_policy={
"view": _canonical_allowed(permissions, "tcm.diagnosis/readonlyDetail"),
"edit": _canonical_allowed(permissions, "tcm.diagnosis/edit"),
"prescription": _canonical_allowed(permissions, "tcm.diagnosis/kaifang"),
"ai_consult": can_open_ai_consult(permissions),
"appointment": _canonical_allowed(permissions, "tcm.diagnosis/guahao"),
"assign": _canonical_allowed(permissions, "tcm.diagnosis/assign"),
"delete": _canonical_allowed(permissions, "tcm.diagnosis/delete"),
@@ -1526,15 +1536,14 @@ class ConsultationsPage(QWidget):
self.table_host.video_requested.connect(self._row_video)
self.table_host.checked_changed.connect(self._checked_changed)
self.table_host.sort_unserved_requested.connect(self._sort_unserved)
table_wrap_layout.addWidget(self.table_host)
table_wrap_layout.addWidget(self.table_host, 1)
self.loading_overlay = DiagnosisLoadingOverlay(self.table_host)
card_layout.addWidget(table_wrap)
card_layout.addWidget(table_wrap, 1)
self.pager = DiagnosisPager(self._page_size)
self.pager.page_changed.connect(self._change_page)
self.pager.page_size_changed.connect(self._change_page_size)
card_layout.addWidget(self.pager)
page_layout.addWidget(card)
page_layout.addStretch(1)
page_layout.addWidget(card, 1)
self.poll_timer = QTimer(self)
self.poll_timer.setInterval(20_000)
@@ -1703,7 +1712,9 @@ class ConsultationsPage(QWidget):
def _toggle_advanced_filters(self, checked: bool) -> None:
self.advanced_filters.setVisible(checked)
self.filters_card.setFixedHeight(
108 + self.advanced_filters.sizeHint().height() + 8 if checked else 108
_FILTERS_COLLAPSED_HEIGHT + self.advanced_filters.sizeHint().height() + 8
if checked
else _FILTERS_COLLAPSED_HEIGHT
)
self.more_filter_button.setText("收起" if checked else "更多筛选")
self.more_filter_button.setArrowType(
@@ -2244,6 +2255,7 @@ class ConsultationsPage(QWidget):
"view": self._open_readonly,
"edit": self._open_edit,
"prescription": self._open_prescription,
"ai_consult": self._open_ai_consult,
"appointment": self._book_selected_appointment,
"fill_id_card": self._fill_selected_id_card,
"assign": self._assign_selected,
@@ -2954,6 +2966,21 @@ class ConsultationsPage(QWidget):
return
self._diagnosis_dialog.open_view_only(diagnosis_id, seed=record)
def _open_ai_consult(self) -> None:
record = self.table.current_data()
diagnosis_id = _as_int(first_value(record, "diagnosis_id", "id", default=0))
present_ai_consult(
self.repository,
self.permissions,
self,
diagnosis_id=diagnosis_id,
patient_id=_as_int(
first_value(record, "source_patient_id", "patient_id", default=0)
),
seed=record,
source_title="患者列表",
)
def _open_edit(self) -> None:
if not _canonical_allowed(self.permissions, "tcm.diagnosis/edit"):
return
@@ -3324,7 +3351,11 @@ class ConsultationsPage(QWidget):
def _poll_refresh(self) -> None:
"""Refresh rows and chip counts without showing the table mask."""
if not self.isVisible() or self._order_flow_generation is not None:
if (
not self.isVisible()
or self._loading
or self._order_flow_generation is not None
):
return
self.refresh(silent=True)
self._refresh_counts()
+90 -66
View File
@@ -37,7 +37,8 @@ from PySide6.QtWidgets import (
)
from ..appointment_drawer import AppointmentDrawer
from ..dialogs import DiagnosisDialog, present_order_detail
from ..dialogs import DiagnosisDialog, present_ai_consult, present_order_detail
from ..dialogs.ai_consult import can_open_ai_consult
from ..dialogs.prescription import PrescriptionOrderListDialog
from ..theme import mark_business_dialog
from ..widgets import (
@@ -98,7 +99,7 @@ _SEMANTIC_COLORS = {
}
PATIENTS_LIGHT_QSS = """
#PatientsPage QWidget#PageHeader { min-height: 88px; max-height: 88px; }
#PatientsPage QWidget#PageHeader { min-height: 62px; max-height: 62px; }
#PatientsPage QWidget#PageHeader QLabel[role="pageTitle"] {
color: #10204A;
font-size: 20px;
@@ -109,8 +110,8 @@ PATIENTS_LIGHT_QSS = """
font-size: 12px;
}
#PatientsPage QPushButton[patientSearchAction="true"] {
min-height: 34px;
max-height: 34px;
min-height: 32px;
max-height: 32px;
color: #FFFFFF;
background-color: #5265F6;
border: 1px solid #5265F6;
@@ -122,24 +123,24 @@ PATIENTS_LIGHT_QSS = """
border-color: #4557E7;
}
#PatientsPage QFrame[patientListFilter="true"] {
min-height: 112px;
max-height: 112px;
min-height: 88px;
max-height: 88px;
background-color: #FFFFFF;
border: 1px solid #E6EAF5;
border-radius: 11px;
}
#PatientsPage QFrame[patientListFilter="true"] QLineEdit,
#PatientsPage QFrame[patientListFilter="true"] QDateEdit {
min-height: 34px;
max-height: 34px;
min-height: 32px;
max-height: 32px;
background-color: #FFFFFF;
border: 1px solid #E2E7F4;
border-radius: 7px;
}
#PatientsPage QPushButton[patientStatusChip="true"] {
min-height: 34px;
max-height: 34px;
padding: 0 13px;
min-height: 32px;
max-height: 32px;
padding: 0 10px;
color: #405074;
background-color: #F8F9FD;
border: 0;
@@ -155,9 +156,9 @@ PATIENTS_LIGHT_QSS = """
border: 1px solid #9EA8FF;
}
#PatientsPage QPushButton[patientQuickDate="true"] {
min-height: 34px;
max-height: 34px;
padding: 0 12px;
min-height: 32px;
max-height: 32px;
padding: 0 9px;
color: #29365C;
background-color: transparent;
border: 1px solid transparent;
@@ -179,7 +180,7 @@ PATIENTS_LIGHT_QSS = """
}
#PatientsPage QTabWidget#PatientWorkspaceTabs QTabBar::tab {
min-width: 86px;
min-height: 40px;
min-height: 34px;
padding: 0 6px;
margin-right: 8px;
color: #59698E;
@@ -193,9 +194,9 @@ PATIENTS_LIGHT_QSS = """
border-bottom-color: #5265F6;
}
#PatientsPage QPushButton[summaryCard="true"] {
min-height: 54px;
max-height: 54px;
padding: 0 13px;
min-height: 42px;
max-height: 42px;
padding: 0 11px;
color: #5265F6;
background-color: #F7F8FF;
border: 1px solid #E1E5FF;
@@ -1277,6 +1278,7 @@ class PatientListWorkspace(QWidget):
fill_id_requested = Signal(object)
cancel_requested = Signal(object)
orders_requested = Signal(object)
ai_consult_requested = Signal(object)
scope_changed = Signal(str)
def __init__(self, repository: Any, permissions: Any, parent: QWidget | None = None) -> None:
@@ -1289,15 +1291,18 @@ class PatientListWorkspace(QWidget):
self._date_mode = "all"
self._scope = "按权限加载"
self._setting_dates = False
self.setMinimumHeight(0)
root = QVBoxLayout(self)
root.setContentsMargins(0, 2, 0, 0)
root.setSpacing(10)
root.addWidget(self._build_filters())
root.setSpacing(6)
self.filter_card = self._build_filters()
root.addWidget(self.filter_card)
root.addLayout(self._build_summary())
self.banner = MessageBanner()
root.addWidget(self.banner)
root.addWidget(self._build_table(), 1)
self.table_card = self._build_table()
root.addWidget(self.table_card, 1)
@property
def scope(self) -> str:
@@ -1308,20 +1313,17 @@ class PatientListWorkspace(QWidget):
card.setObjectName("FilterBar")
card.setProperty("patientListFilter", True)
panel = QVBoxLayout(card)
panel.setContentsMargins(18, 18, 18, 12)
panel.setSpacing(10)
panel.setContentsMargins(12, 8, 12, 8)
panel.setSpacing(6)
top_row = QHBoxLayout()
top_row.setContentsMargins(0, 0, 0, 0)
top_row.setSpacing(0)
top_row.setSpacing(6)
self.keyword_edit = QLineEdit()
self.keyword_edit.setPlaceholderText("患者姓名 / 手机号 / 助理 / 医生")
self.keyword_edit.setClearButtonEnabled(True)
self.keyword_edit.setMinimumWidth(300)
self.keyword_edit.setMaximumWidth(605)
self.keyword_edit.returnPressed.connect(self.search)
top_row.addWidget(self.keyword_edit, 1)
top_row.addSpacing(21)
top_row.addWidget(self.keyword_edit, 3)
# Compatibility-only control: it is intentionally hidden and never
# inserted into a layout, so it needs an explicit parent to avoid
# becoming a transient top-level Windows HWND during page creation.
@@ -1332,11 +1334,10 @@ class PatientListWorkspace(QWidget):
self.status_combo.addItem("已完成", "completed")
self.status_combo.addItem("已过号", "missed")
self.status_combo.hide()
status_host = QWidget()
status_host.setFixedWidth(396)
status_row = QHBoxLayout(status_host)
self.status_host = QWidget()
status_row = QHBoxLayout(self.status_host)
status_row.setContentsMargins(0, 0, 0, 0)
status_row.setSpacing(0)
status_row.setSpacing(2)
self.status_group = QButtonGroup(self)
self.status_group.setExclusive(True)
self.status_buttons: dict[str, QPushButton] = {}
@@ -1368,30 +1369,25 @@ class PatientListWorkspace(QWidget):
self.status_buttons[value] = button
status_row.addWidget(button, 1)
self.status_buttons[""].setChecked(True)
top_row.addWidget(status_host)
top_row.addStretch(1)
top_row.addWidget(self.status_host, 2)
search = QPushButton("查询")
search.setProperty("variant", "primary")
search.setProperty("patientSearchAction", True)
search.setFixedWidth(72)
search.clicked.connect(self.search)
top_row.addWidget(search)
top_row.addSpacing(12)
reset = QPushButton("重置")
reset.setProperty("variant", "ghost")
reset.setFixedWidth(72)
reset.clicked.connect(self.reset_filters)
top_row.addWidget(reset)
panel.addLayout(top_row)
bottom_row = QHBoxLayout()
bottom_row.setContentsMargins(0, 0, 0, 0)
bottom_row.setSpacing(0)
quick_host = QWidget()
quick_host.setFixedWidth(730)
quick = QHBoxLayout(quick_host)
bottom_row.setSpacing(6)
self.quick_host = QWidget()
quick = QHBoxLayout(self.quick_host)
quick.setContentsMargins(0, 0, 0, 0)
quick.setSpacing(0)
quick.setSpacing(2)
self.quick_group = QButtonGroup(self)
self.quick_group.setExclusive(True)
self.quick_buttons: dict[str, QPushButton] = {}
@@ -1412,15 +1408,12 @@ class PatientListWorkspace(QWidget):
self.quick_buttons[mode] = button
quick.addWidget(button, 1)
self.quick_buttons["all"].setChecked(True)
bottom_row.addWidget(quick_host)
bottom_row.addSpacing(20)
bottom_row.addWidget(self.quick_host, 3)
date_host = QWidget()
date_host.setMinimumWidth(390)
date_host.setMaximumWidth(476)
dates = QHBoxLayout(date_host)
self.date_host = QWidget()
dates = QHBoxLayout(self.date_host)
dates.setContentsMargins(0, 0, 0, 0)
dates.setSpacing(10)
dates.setSpacing(6)
self.start_date = QDateEdit(QDate.currentDate())
self.start_date.setCalendarPopup(True)
self.start_date.setDisplayFormat("yyyy-MM-dd")
@@ -1430,7 +1423,6 @@ class PatientListWorkspace(QWidget):
separator = QLabel("~")
separator.setAlignment(Qt.AlignmentFlag.AlignCenter)
separator.setProperty("role", "muted")
separator.setFixedWidth(24)
dates.addWidget(separator)
self.end_date = QDateEdit(QDate.currentDate())
self.end_date.setCalendarPopup(True)
@@ -1438,14 +1430,11 @@ class PatientListWorkspace(QWidget):
self.end_date.setEnabled(False)
self.end_date.editingFinished.connect(self._custom_date_changed)
dates.addWidget(self.end_date, 1)
bottom_row.addWidget(date_host, 1)
bottom_row.addSpacing(20)
bottom_row.addWidget(self.date_host, 2)
custom = QPushButton("自定义")
custom.setProperty("variant", "ghost")
custom.setFixedWidth(100)
custom.clicked.connect(lambda: self.set_date_mode("custom"))
bottom_row.addWidget(custom)
bottom_row.addStretch(1)
panel.addLayout(bottom_row)
return card
@@ -1460,7 +1449,7 @@ class PatientListWorkspace(QWidget):
):
button = QPushButton(f"{label}\n0 人")
button.setProperty("summaryCard", True)
button.setFixedHeight(56)
button.setFixedHeight(42)
button.setIcon(_summary_calendar_icon())
button.setIconSize(QSize(34, 34))
button.clicked.connect(lambda _checked=False, value=mode: self.set_date_mode(value))
@@ -1471,9 +1460,10 @@ class PatientListWorkspace(QWidget):
def _build_table(self) -> QWidget:
card = QFrame()
card.setObjectName("Card")
card.setMinimumHeight(0)
layout = QVBoxLayout(card)
layout.setContentsMargins(14, 12, 14, 12)
layout.setSpacing(8)
layout.setContentsMargins(10, 8, 10, 8)
layout.setSpacing(6)
heading = QHBoxLayout()
title = QLabel("患者列表")
title.setProperty("role", "sectionTitle")
@@ -1485,10 +1475,12 @@ class PatientListWorkspace(QWidget):
heading.addWidget(self.scope_label)
layout.addLayout(heading)
self.content_stack = QStackedWidget()
self.content_stack.setMinimumHeight(0)
host = QWidget()
host.setMinimumHeight(0)
host_layout = QVBoxLayout(host)
host_layout.setContentsMargins(0, 0, 0, 0)
host_layout.setSpacing(8)
host_layout.setSpacing(6)
self.table = SortableTable(
[
TableColumn("_selected", "", 40, alignment=Qt.AlignmentFlag.AlignCenter),
@@ -1524,17 +1516,23 @@ class PatientListWorkspace(QWidget):
TableColumn("confirmation_text", "确认信息", 88),
TableColumn("diagnosis_date_text", "诊单日期", 96),
TableColumn("phone_masked", "手机", 116),
TableColumn("_actions", "操作", 430),
TableColumn("_actions", "操作", 500),
]
)
self.table.setObjectName("PatientTable")
self.table.horizontalHeader().setFixedHeight(38)
self.table.setMinimumHeight(0)
self.table.horizontalHeader().setFixedHeight(34)
self.table.verticalHeader().setDefaultSectionSize(40)
self.table.itemSelectionChanged.connect(self._update_actions)
self.table.itemDoubleClicked.connect(lambda _item: self._open_selected_diagnosis())
host_layout.addWidget(self.table, 1)
host_layout.addLayout(self._build_actions())
self.bottom_actions = QWidget(host)
self.bottom_actions.setObjectName("PatientBottomActions")
self.bottom_actions.setLayout(self._build_actions())
self.bottom_actions.hide()
host_layout.addWidget(self.bottom_actions)
self.pager = Pager(self._page_size)
self.pager.setMaximumHeight(38)
self.pager.page_changed.connect(self._change_page)
host_layout.addWidget(self.pager)
self.content_stack.addWidget(host)
@@ -1594,6 +1592,11 @@ class PatientListWorkspace(QWidget):
value, can_edit
),
)
if can_open_ai_consult(self.permissions):
add_action(
"AI 分析",
lambda _checked=False, value=row: self.ai_consult_requested.emit(value),
)
if can_book:
add_action(
"预约",
@@ -1636,6 +1639,12 @@ class PatientListWorkspace(QWidget):
self.diagnosis_button.setProperty("variant", "primary")
self.diagnosis_button.clicked.connect(self._open_selected_diagnosis)
layout.addWidget(self.diagnosis_button)
self.ai_consult_button = QPushButton("AI 分析", self)
self.ai_consult_button.clicked.connect(
lambda: self._emit_selected(self.ai_consult_requested)
)
self.ai_consult_button.setVisible(can_open_ai_consult(self.permissions))
layout.addWidget(self.ai_consult_button)
self.appointment_button = QPushButton("预约", self)
self.appointment_button.clicked.connect(
lambda: self._emit_selected(self.appointment_requested)
@@ -1682,6 +1691,8 @@ class PatientListWorkspace(QWidget):
self.diagnosis_button.setVisible(editable or readable)
self.diagnosis_button.setText("诊单" if editable else "查看")
self.diagnosis_button.setEnabled(selected)
self.ai_consult_button.setVisible(can_open_ai_consult(self.permissions))
self.ai_consult_button.setEnabled(selected)
self.appointment_button.setVisible(can_book)
self.appointment_button.setEnabled(selected)
self.assign_button.setVisible(can_assign)
@@ -2606,19 +2617,20 @@ class PatientsPage(QWidget):
self._assistant_generation = 0
root = QVBoxLayout(self)
root.setContentsMargins(25, 4, 30, 14)
root.setContentsMargins(20, 2, 24, 10)
root.setSpacing(0)
header = PageHeader("我的患者", "患者、挂号与诊单按当前角色和部门数据范围展示。")
self.header = PageHeader("我的患者", "患者、挂号与诊单按当前角色和部门数据范围展示。")
self.scope_badge = StatusBadge("按权限加载", "neutral")
header.add_action(self.scope_badge)
self.header.add_action(self.scope_badge)
refresh = QPushButton("刷新")
refresh.setProperty("variant", "primary")
refresh.clicked.connect(self.refresh)
header.add_action(refresh)
root.addWidget(header)
self.header.add_action(refresh)
root.addWidget(self.header)
self.tabs = QTabWidget()
self.tabs.setObjectName("PatientWorkspaceTabs")
self.tabs.setMinimumHeight(0)
self.patient_workspace = PatientListWorkspace(repository, permissions)
self.order_workspace = PatientOrdersWorkspace(repository, permissions)
self.progress_workspace = PatientProgressWorkspace(repository)
@@ -2628,6 +2640,7 @@ class PatientsPage(QWidget):
root.addWidget(self.tabs, 1)
self.patient_workspace.diagnosis_requested.connect(self._open_diagnosis)
self.patient_workspace.ai_consult_requested.connect(self._open_ai_consult)
self.patient_workspace.appointment_requested.connect(self._book_appointment)
self.patient_workspace.assign_requested.connect(self._load_assistants)
self.patient_workspace.fill_id_requested.connect(self._fill_id_card)
@@ -2689,6 +2702,17 @@ class PatientsPage(QWidget):
else:
self._ensure_diagnosis_dialog().open_view_only(diagnosis_id, seed=row)
def _open_ai_consult(self, row: Any) -> None:
present_ai_consult(
self.repository,
self.permissions,
self,
diagnosis_id=self._diagnosis_id(row),
patient_id=_as_int(first_value(row, "patient_id", "source_patient_id", default=0)),
seed=row,
source_title="我的患者",
)
def _open_order_diagnosis(self, row: Any) -> None:
editable = _canonical_allowed(self.permissions, "tcm.diagnosis/edit")
readable = _canonical_allowed(self.permissions, "tcm.diagnosis/readonlyDetail")
@@ -8,7 +8,6 @@ from typing import Any
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QColor, QFont
from PySide6.QtWidgets import (
QAbstractItemView,
QComboBox,
QDialog,
QFrame,
@@ -26,6 +25,7 @@ from PySide6.QtWidgets import (
from ..dialogs.prescription import PrescriptionTemplateDialog
from ..dialogs.prescription_ai import PrescriptionAiReportDialog, can_open_ai_explain
from ..widgets import (
BusinessPager,
EmptyState,
MessageBanner,
MetricCard,
@@ -44,7 +44,6 @@ from ..widgets import (
show_toast,
)
from .prescriptions import (
BusinessPager,
_cell_host,
_painted_icon,
_row_action_button,
@@ -54,27 +53,27 @@ from .prescriptions import (
PRESCRIPTION_LIBRARY_PAGE_QSS = """
#PrescriptionLibraryPage { background: #F8FAFF; }
#PrescriptionLibraryPage QWidget#PageHeader { min-height: 84px; max-height: 84px; }
#PrescriptionLibraryPage QWidget#PageHeader { min-height: 62px; max-height: 62px; }
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumb"],
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumbSeparator"],
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="breadcrumbCurrent"] {
min-height: 16px; max-height: 16px;
min-height: 14px; max-height: 14px;
}
#PrescriptionLibraryPage QLabel[role="pageTitle"] {
color: #15224A; font-size: 20px; font-weight: 700;
}
#PrescriptionLibraryPage QWidget#PageHeader QLabel[role="muted"] {
color: #7481A3; font-size: 12px; padding-top: 5px;
color: #7481A3; font-size: 12px;
}
#PrescriptionLibraryPage QFrame#MetricCard {
min-height: 80px; max-height: 80px;
min-height: 64px; max-height: 64px;
border: 1px solid #E2E7F4; border-radius: 12px; background: #FFFFFF;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricTitle"] {
color: #405074; font-size: 12px; font-weight: 600;
}
#PrescriptionLibraryPage QFrame#MetricCard QLabel[role="metricValue"] {
color: #5265F6; font-size: 22px; font-weight: 700;
color: #5265F6; font-size: 20px; font-weight: 700;
}
#PrescriptionLibraryPage QLabel[metricIcon="true"] {
border: 1px solid #DCE3FF; border-radius: 11px; background: #EEF1FF;
@@ -97,7 +96,7 @@ PRESCRIPTION_LIBRARY_PAGE_QSS = """
border-top-left-radius: 13px; border-top-right-radius: 13px;
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryToolbar QPushButton[toolbarTab="true"] {
min-width: 82px; min-height: 36px; max-height: 36px;
min-width: 82px; min-height: 32px; max-height: 32px;
padding: 0 8px; margin: 0 4px 0 0;
color: #59698E; background: transparent; border: 0;
border-bottom: 2px solid transparent; border-radius: 0;
@@ -107,7 +106,7 @@ PRESCRIPTION_LIBRARY_PAGE_QSS = """
}
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar QLineEdit,
#PrescriptionLibraryPage QFrame#PrescriptionLibraryFilterBar QComboBox {
min-height: 36px; max-height: 36px; padding: 0 11px;
min-height: 32px; max-height: 32px; padding: 0 11px;
border-radius: 8px; font-size: 12px;
}
#PrescriptionLibraryPage QPushButton {
@@ -159,11 +158,14 @@ PRESCRIPTION_LIBRARY_PAGE_QSS = """
min-width: 34px; max-width: 34px; min-height: 32px; max-height: 32px;
padding: 0; background: #FFFFFF; color: #405074; border-color: #E2E7F4;
}
#PrescriptionLibraryPage QWidget#BusinessPager QPushButton {
min-height: 32px; max-height: 32px;
}
#PrescriptionLibraryPage QWidget#BusinessPager QPushButton[pagerPage="true"][active="true"] {
background: #5265F6; color: #FFFFFF; border-color: #5265F6;
}
#PrescriptionLibraryPage QWidget#BusinessPager QComboBox {
min-height: 32px; max-height: 32px; min-width: 92px; padding: 0 9px;
#PrescriptionLibraryPage QWidget#BusinessPager QLabel[pagerSize="true"] {
min-width: 64px; color: #7481A3; font-size: 12px;
}
"""
@@ -214,13 +216,13 @@ def _efficacy_text(_value: Any, row: Any) -> str:
def _metric_card(title: str, kind: str = "accent") -> MetricCard:
card = MetricCard(title, "0", kind=kind, glyph="")
card.setFixedHeight(80)
card.layout().setContentsMargins(18, 12, 16, 12)
card.setFixedHeight(64)
card.layout().setContentsMargins(16, 8, 14, 8)
icon = QLabel(card)
icon.setProperty("metricIcon", True)
icon.setProperty("kind", kind)
icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
icon.setFixedSize(42, 42)
icon.setFixedSize(36, 36)
colors = {
"accent": "#5365F5",
"info": "#8268E8",
@@ -261,6 +263,7 @@ class PrescriptionLibraryPage(QWidget):
"处方库",
"管理常用处方模板,支持 AI 解析辅助开方。",
)
header.layout().setSpacing(4)
header.actions.setSpacing(16)
self.new_button = QPushButton("新增处方", header)
self.new_button.setMinimumWidth(124)
@@ -274,8 +277,8 @@ class PrescriptionLibraryPage(QWidget):
root.addWidget(header)
metrics = QHBoxLayout()
metrics.setContentsMargins(0, 0, 0, 5)
metrics.setSpacing(24)
metrics.setContentsMargins(0, 0, 0, 0)
metrics.setSpacing(16)
self.metric_cards = {
"total": _metric_card("全部处方"),
"private": _metric_card("仅自己", "info"),
@@ -295,9 +298,10 @@ class PrescriptionLibraryPage(QWidget):
filters = QFrame()
filters.setObjectName("PrescriptionLibraryFilterBar")
filters.setFixedHeight(52)
grid = QGridLayout(filters)
grid.setContentsMargins(16, 11, 16, 11)
grid.setHorizontalSpacing(20)
grid.setContentsMargins(16, 9, 16, 9)
grid.setHorizontalSpacing(12)
self.name_filter = QLineEdit()
self.name_filter.setPlaceholderText("搜索处方名称、药材、功效等关键词")
self.name_filter.setClearButtonEnabled(True)
@@ -319,10 +323,10 @@ class PrescriptionLibraryPage(QWidget):
self.effect_filter.addItem("清热祛湿", "清热祛湿")
self.effect_filter.addItem("滋阴补肾", "滋阴补肾")
grid.addWidget(self.effect_filter, 0, 3)
self.name_filter.setMinimumWidth(500)
self.formula_filter.setFixedWidth(190)
self.visibility_filter.setFixedWidth(174)
self.effect_filter.setFixedWidth(190)
self.name_filter.setMinimumWidth(220)
self.formula_filter.setMinimumWidth(132)
self.visibility_filter.setMinimumWidth(148)
self.effect_filter.setMinimumWidth(144)
self.query_button = QPushButton("查询")
self.query_button.setFixedWidth(66)
self.query_button.setProperty("variant", "secondary")
@@ -335,7 +339,10 @@ class PrescriptionLibraryPage(QWidget):
self.reset_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.reset_button.clicked.connect(self._reset_filters)
grid.addWidget(self.reset_button, 0, 5)
grid.setColumnStretch(0, 1)
grid.setColumnStretch(0, 4)
grid.setColumnStretch(1, 1)
grid.setColumnStretch(2, 1)
grid.setColumnStretch(3, 1)
root.addWidget(filters)
self.banner = MessageBanner()
@@ -347,8 +354,9 @@ class PrescriptionLibraryPage(QWidget):
card_layout.setSpacing(0)
toolbar_host = QFrame(card)
toolbar_host.setObjectName("PrescriptionLibraryToolbar")
toolbar_host.setFixedHeight(46)
toolbar = QHBoxLayout(toolbar_host)
toolbar.setContentsMargins(16, 10, 16, 9)
toolbar.setContentsMargins(16, 7, 16, 7)
toolbar.setSpacing(8)
self.all_tab = QPushButton("处方列表", toolbar_host)
self.all_tab.setProperty("toolbarTab", True)
@@ -423,10 +431,9 @@ class PrescriptionLibraryPage(QWidget):
TableColumn("__actions__", "操作", 160, lambda _value, _row: ""),
]
)
self.table.verticalHeader().setDefaultSectionSize(37)
self.table.horizontalHeader().setFixedHeight(41)
self.table.verticalHeader().setDefaultSectionSize(36)
self.table.horizontalHeader().setFixedHeight(38)
self.table.setWordWrap(False)
self.table.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.table.itemSelectionChanged.connect(self._selection_changed)
self.table.itemDoubleClicked.connect(lambda _item: self._view_selected())
table_layout.addWidget(self.table, 1)
@@ -6,10 +6,9 @@ from collections.abc import Callable, Mapping
from datetime import datetime, timedelta
from typing import Any
from PySide6.QtCore import QDateTime, QRectF, QSize, Qt, Signal
from PySide6.QtCore import QDateTime, QRectF, QSize, Qt
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPen, QPixmap
from PySide6.QtWidgets import (
QAbstractItemView,
QComboBox,
QDateTimeEdit,
QDialog,
@@ -38,6 +37,7 @@ from ..dialogs.prescription import (
PrescriptionOrderListDialog,
)
from ..widgets import (
BusinessPager,
EmptyState,
MessageBanner,
PageHeader,
@@ -57,17 +57,17 @@ from ..widgets import (
PRESCRIPTIONS_PAGE_QSS = """
#PrescriptionsPage { background: #F8FAFF; }
#PrescriptionsPage QWidget#PageHeader { min-height: 84px; max-height: 84px; }
#PrescriptionsPage QWidget#PageHeader { min-height: 62px; max-height: 62px; }
#PrescriptionsPage QWidget#PageHeader QLabel[role="breadcrumb"],
#PrescriptionsPage QWidget#PageHeader QLabel[role="breadcrumbSeparator"],
#PrescriptionsPage QWidget#PageHeader QLabel[role="breadcrumbCurrent"] {
min-height: 16px; max-height: 16px;
min-height: 14px; max-height: 14px;
}
#PrescriptionsPage QLabel[role="pageTitle"] {
color: #15224A; font-size: 20px; font-weight: 700;
}
#PrescriptionsPage QWidget#PageHeader QLabel[role="muted"] {
color: #7481A3; font-size: 12px; padding-top: 5px;
color: #7481A3; font-size: 12px;
}
#PrescriptionsPage QFrame#PrescriptionFilterBar,
#PrescriptionsPage QFrame#PrescriptionTableCard {
@@ -85,7 +85,7 @@ PRESCRIPTIONS_PAGE_QSS = """
#PrescriptionsPage QFrame#PrescriptionFilterBar QLineEdit,
#PrescriptionsPage QFrame#PrescriptionFilterBar QComboBox,
#PrescriptionsPage QFrame#PrescriptionFilterBar QDateTimeEdit {
min-height: 34px; max-height: 34px; padding: 0 11px;
min-height: 32px; max-height: 32px; padding: 0 11px;
border-radius: 8px; font-size: 12px;
}
#PrescriptionsPage QPushButton {
@@ -142,11 +142,14 @@ PRESCRIPTIONS_PAGE_QSS = """
min-width: 34px; max-width: 34px; min-height: 32px; max-height: 32px;
padding: 0; background: #FFFFFF; color: #405074; border-color: #E2E7F4;
}
#PrescriptionsPage QWidget#BusinessPager QPushButton {
min-height: 32px; max-height: 32px;
}
#PrescriptionsPage QWidget#BusinessPager QPushButton[pagerPage="true"][active="true"] {
background: #5265F6; color: #FFFFFF; border-color: #5265F6;
}
#PrescriptionsPage QWidget#BusinessPager QComboBox {
min-height: 32px; max-height: 32px; min-width: 92px; padding: 0 9px;
#PrescriptionsPage QWidget#BusinessPager QLabel[pagerSize="true"] {
min-width: 64px; color: #7481A3; font-size: 12px;
}
"""
@@ -276,88 +279,6 @@ def _row_action_button(
return button
class BusinessPager(QWidget):
"""Reference-style numbered pager while retaining the page's fixed size contract."""
page_changed = Signal(int)
def __init__(self, page_size: int = 15, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("BusinessPager")
self.page = 1
self.page_size = page_size
self.total = 0
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 6, 0, 0)
layout.setSpacing(7)
self.summary = QLabel("共 0 条", self)
self.summary.setProperty("role", "muted")
layout.addWidget(self.summary)
layout.addStretch(1)
self.previous = QPushButton("‹", self)
self.previous.setProperty("variant", "ghost")
self.previous.clicked.connect(lambda: self._request(self.page - 1))
layout.addWidget(self.previous)
self.pages_host = QWidget(self)
self.pages_layout = QHBoxLayout(self.pages_host)
self.pages_layout.setContentsMargins(0, 0, 0, 0)
self.pages_layout.setSpacing(5)
layout.addWidget(self.pages_host)
self.next = QPushButton("›", self)
self.next.setProperty("variant", "ghost")
self.next.clicked.connect(lambda: self._request(self.page + 1))
layout.addWidget(self.next)
self.size_combo = QComboBox(self)
self.size_combo.addItem(f"{page_size} 条/页", page_size)
layout.addWidget(self.size_combo)
self.page_label: QPushButton | None = None
self.update_state(1, 0)
@property
def page_count(self) -> int:
return max(1, (self.total + self.page_size - 1) // self.page_size)
def update_state(self, page: int, total: int) -> None:
self.page = max(1, page)
self.total = max(0, total)
self.summary.setText(f"共 {self.total} 条")
while self.pages_layout.count():
item = self.pages_layout.takeAt(0)
if item.widget() is not None:
item.widget().deleteLater()
count = self.page_count
if count <= 4:
pages: list[int | None] = list(range(1, count + 1))
elif self.page <= 3:
pages = [1, 2, 3, None, count]
elif self.page >= count - 2:
pages = [1, None, count - 2, count - 1, count]
else:
pages = [1, None, self.page, None, count]
self.page_label = None
for number in pages:
if number is None:
ellipsis = QLabel("…", self.pages_host)
ellipsis.setAlignment(Qt.AlignmentFlag.AlignCenter)
ellipsis.setFixedWidth(24)
self.pages_layout.addWidget(ellipsis)
continue
button = QPushButton(str(number), self.pages_host)
button.setProperty("pagerPage", True)
button.setProperty("active", number == self.page)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.clicked.connect(lambda _checked=False, value=number: self._request(value))
self.pages_layout.addWidget(button)
if number == self.page:
self.page_label = button
self.previous.setEnabled(self.page > 1)
self.next.setEnabled(self.page < count)
def _request(self, page: int) -> None:
if 1 <= page <= self.page_count and page != self.page:
self.page_changed.emit(page)
def _int(value: Any, default: int = 0) -> int:
try:
return int(value)
@@ -609,6 +530,7 @@ class PrescriptionsPage(QWidget):
"已开处方",
"管理处方审核、患者修正与履约订单;已通过且未作废的处方只允许查看。",
)
header.layout().setSpacing(4)
header.actions.setSpacing(16)
self.orders_button = QPushButton("业务订单", header)
self.orders_button.setMinimumWidth(86)
@@ -635,10 +557,11 @@ class PrescriptionsPage(QWidget):
def _build_filters(self) -> QWidget:
frame = QFrame()
frame.setObjectName("PrescriptionFilterBar")
frame.setFixedHeight(88)
grid = QGridLayout(frame)
grid.setContentsMargins(14, 17, 14, 17)
grid.setContentsMargins(16, 8, 16, 8)
grid.setHorizontalSpacing(18)
grid.setVerticalSpacing(15)
grid.setVerticalSpacing(8)
self.quick_date = QComboBox()
self.quick_date.addItem("全部时间", "all")
self.quick_date.addItem("今日", "today")
@@ -712,8 +635,9 @@ class PrescriptionsPage(QWidget):
layout.setSpacing(0)
toolbar_host = QFrame(card)
toolbar_host.setObjectName("PrescriptionToolbar")
toolbar_host.setFixedHeight(46)
toolbar = QHBoxLayout(toolbar_host)
toolbar.setContentsMargins(16, 11, 16, 11)
toolbar.setContentsMargins(16, 7, 16, 7)
toolbar.setSpacing(8)
title = QLabel("处方列表")
title.setProperty("role", "sectionTitle")
@@ -772,7 +696,6 @@ class PrescriptionsPage(QWidget):
self.table.verticalHeader().setDefaultSectionSize(36)
self.table.horizontalHeader().setFixedHeight(38)
self.table.setWordWrap(False)
self.table.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.table.horizontalHeaderItem(0).setIcon(_painted_icon("checkbox", "#AEB9D4", 14))
self.table.horizontalHeaderItem(0).setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.table.itemSelectionChanged.connect(self._selection_changed)
@@ -1434,6 +1357,7 @@ class PrescriptionsPage(QWidget):
__all__ = [
"BusinessPager",
"PrescriptionsPage",
"can_audit",
"can_create_order",
File diff suppressed because it is too large Load Diff
+67 -5
View File
@@ -4,9 +4,10 @@ from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from functools import wraps
from typing import Any
from PySide6.QtCore import QPointF, QRectF, QSize, Qt, Signal
from PySide6.QtCore import QPointF, QRectF, QSize, Qt, QTimer, Signal
from PySide6.QtGui import (
QColor,
QFont,
@@ -863,6 +864,10 @@ class ShellWindow(QMainWindow):
self.page_titles: dict[int, str] = {}
self._fixed_tab_key: str | None = None
self._sidebar_collapsed = False
self._active_page_key: str | None = None
self._activation_page: QWidget | None = None
self._activation_generation = 0
self._activation_refreshed = False
self.setMinimumSize(1024, 640)
self.resize(1710, 920)
@@ -1614,6 +1619,7 @@ class ShellWindow(QMainWindow):
current_user=self.current_user,
parent=self.stack,
)
self._install_activation_refresh_gate(page)
if hasattr(page, "video_requested"):
page.video_requested.connect(
lambda payload: self.video_requested.emit(payload)
@@ -1662,9 +1668,49 @@ class ShellWindow(QMainWindow):
),
)
def _install_activation_refresh_gate(self, page: QWidget) -> None:
"""Coalesce lifecycle and shell refreshes during one page activation."""
refresh = getattr(page, "refresh", None)
if not callable(refresh):
return
@wraps(refresh)
def activation_refresh(*args: Any, **kwargs: Any) -> Any:
if page is self._activation_page:
if self._activation_refreshed:
return None
self._activation_refreshed = True
return refresh(*args, **kwargs)
page.refresh = activation_refresh # type: ignore[attr-defined,method-assign]
def _ensure_activation_refresh(self, page: QWidget, generation: int) -> None:
if generation != self._activation_generation or page is not self._activation_page:
return
if not self._activation_refreshed:
refresh = getattr(page, "refresh", None)
if callable(refresh):
refresh()
def _finish_activation(self, page: QWidget, generation: int) -> None:
if generation != self._activation_generation or page is not self._activation_page:
return
self._ensure_activation_refresh(page, generation)
self._activation_page = None
def _navigate(self, index: int, key: str) -> None:
if index < 0 or index >= self.stack.count():
return
page = self.stack.widget(index)
if page is None:
return
if self._active_page_key == key and self.stack.currentWidget() is page:
return
self._activation_generation += 1
activation_generation = self._activation_generation
self._activation_page = page
self._activation_refreshed = False
self.stack.setCurrentIndex(index)
if not self._sidebar_collapsed:
self.sidebar.setFixedWidth(self._expanded_sidebar_width(key))
@@ -1675,10 +1721,15 @@ class ShellWindow(QMainWindow):
button = self.nav_buttons.get(key)
if button is not None:
button.setChecked(True)
page = self.stack.widget(index)
refresh = getattr(page, "refresh", None)
if callable(refresh):
refresh()
self._active_page_key = key
self._ensure_activation_refresh(page, activation_generation)
if self.isVisible():
QTimer.singleShot(
0,
lambda page=page, generation=activation_generation: self._finish_activation(
page, generation
),
)
self.page_changed.emit(key)
def navigate(self, key: str) -> bool:
@@ -1697,6 +1748,17 @@ class ShellWindow(QMainWindow):
if callable(refresh):
refresh()
def showEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
super().showEvent(event)
page = self.stack.currentWidget()
if page is None or page is not self._activation_page:
return
generation = self._activation_generation
QTimer.singleShot(
0,
lambda page=page, generation=generation: self._finish_activation(page, generation),
)
def setVisible(self, visible: bool) -> None: # noqa: N802 - Qt API
if visible:
self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, False)
+103
View File
@@ -716,6 +716,9 @@ class SortableTable(QTableWidget):
self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.setMinimumHeight(0)
self.setSortingEnabled(True)
self.verticalHeader().setVisible(False)
self.horizontalHeader().setStretchLastSection(True)
@@ -754,6 +757,105 @@ class SortableTable(QTableWidget):
return item.data(Qt.ItemDataRole.UserRole) if item is not None else None
class BusinessPager(QWidget):
"""Compact numbered pager shared by dense business-list pages."""
page_changed = Signal(int)
def __init__(self, page_size: int = 15, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("BusinessPager")
self.setFixedHeight(42)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.page = 1
self.page_size = page_size
self.total = 0
layout = QHBoxLayout(self)
layout.setContentsMargins(16, 4, 16, 4)
layout.setSpacing(7)
self.summary = QLabel("共 0 条", self)
self.summary.setProperty("role", "muted")
layout.addWidget(self.summary)
layout.addStretch(1)
self.previous = QPushButton("‹", self)
self.previous.setProperty("variant", "ghost")
self.previous.setAccessibleName("上一页")
self.previous.clicked.connect(lambda: self._request(self.page - 1))
layout.addWidget(self.previous)
self.pages_host = QWidget(self)
self.pages_layout = QHBoxLayout(self.pages_host)
self.pages_layout.setContentsMargins(0, 0, 0, 0)
self.pages_layout.setSpacing(5)
layout.addWidget(self.pages_host)
self.next = QPushButton("›", self)
self.next.setProperty("variant", "ghost")
self.next.setAccessibleName("下一页")
self.next.clicked.connect(lambda: self._request(self.page + 1))
layout.addWidget(self.next)
# The business contract fixes this list to one page size. A label is
# intentionally used instead of a one-option combo box so the control
# does not advertise an interaction that cannot change anything.
self.page_size_label = QLabel(f"{page_size} 条/页", self)
self.page_size_label.setProperty("pagerSize", True)
self.page_size_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self.page_size_label)
self.page_label: QPushButton | None = None
self.update_state(1, 0)
@property
def page_count(self) -> int:
return max(1, (self.total + self.page_size - 1) // self.page_size)
def update_state(self, page: int, total: int) -> None:
self.total = max(0, total)
self.page = min(max(1, page), self.page_count)
self.summary.setText(f"共 {self.total} 条")
while self.pages_layout.count():
item = self.pages_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
count = self.page_count
if count <= 4:
pages: list[int | None] = list(range(1, count + 1))
elif self.page <= 3:
pages = [1, 2, 3, None, count]
elif self.page >= count - 2:
pages = [1, None, count - 2, count - 1, count]
else:
pages = [1, None, self.page, None, count]
self.page_label = None
for number in pages:
if number is None:
ellipsis = QLabel("…", self.pages_host)
ellipsis.setAlignment(Qt.AlignmentFlag.AlignCenter)
ellipsis.setFixedWidth(24)
self.pages_layout.addWidget(ellipsis)
continue
button = QPushButton(str(number), self.pages_host)
button.setProperty("pagerPage", True)
button.setProperty("active", number == self.page)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.clicked.connect(lambda _checked=False, value=number: self._request(value))
self.pages_layout.addWidget(button)
if number == self.page:
self.page_label = button
self.previous.setEnabled(self.page > 1)
self.next.setEnabled(self.page < count)
def _request(self, page: int) -> None:
if 1 <= page <= self.page_count and page != self.page:
self.page_changed.emit(page)
class Pager(QWidget):
page_changed = Signal(int)
@@ -833,6 +935,7 @@ def clear_layout(layout: QVBoxLayout | QHBoxLayout) -> None:
__all__ = [
"BusinessPager",
"BusyOverlay",
"EmptyState",
"MessageBanner",