更新
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@
|
||||
|
||||
Windows 直接在项目根目录双击:
|
||||
|
||||
- `一键运行_医生工作站.bat`:优先启动现有成品;没有成品时自动使用 `uv` 准备源码环境并运行。
|
||||
- `一键运行_医生工作站.bat`:在源码目录且 `.venv` 可用时运行最新源码;否则运行现有成品;没有成品时使用 `uv` 准备源码环境并运行。
|
||||
- `一键打包_医生工作站.bat`:自动同步锁定的 Python/Node 依赖,检查冻结 QtWebEngine/QtMultimedia 文件,执行应用与媒体离屏冒烟验证,最后生成 `dist/DoctorWorkstation-Setup-Windows-x64-<版本>.exe` 安装包、便携 ZIP 和 SHA-256 文件。首次使用会自动下载固定版本的 Inno Setup,并校验下载文件的 SHA-256 与数字签名。
|
||||
|
||||
英文稳定别名分别是 `Run_DoctorWorkstation.bat` 和 `Build_DoctorWorkstation.bat`。分发 ZIP 解压后,可直接双击其中的 `Start_DoctorWorkstation.bat`。
|
||||
|
||||
@@ -9,6 +9,7 @@ $FrozenExecutable = Join-Path $ProjectRoot "dist\DoctorWorkstation\DoctorWorksta
|
||||
$ProjectPython = Join-Path $ProjectRoot ".venv\Scripts\python.exe"
|
||||
$ProjectUvCache = Join-Path $ProjectRoot ".uv-cache"
|
||||
$SourceRoot = Join-Path $ProjectRoot "src"
|
||||
$SourceEntry = Join-Path $SourceRoot "doctor_workstation\__main__.py"
|
||||
|
||||
function Test-ProjectPython {
|
||||
param([Parameter(Mandatory = $true)][string]$Candidate)
|
||||
@@ -33,7 +34,13 @@ function Find-Uv {
|
||||
}
|
||||
|
||||
try {
|
||||
if (Test-Path -LiteralPath $FrozenExecutable -PathType Leaf) {
|
||||
$Python = $null
|
||||
if ((Test-Path -LiteralPath $SourceEntry -PathType Leaf) -and
|
||||
(Test-ProjectPython -Candidate $ProjectPython)) {
|
||||
$Python = $ProjectPython
|
||||
Write-Host "Using current source tree: $SourceRoot"
|
||||
}
|
||||
elseif (Test-Path -LiteralPath $FrozenExecutable -PathType Leaf) {
|
||||
Write-Host "Using packaged application: $FrozenExecutable"
|
||||
if ($ValidateOnly) {
|
||||
Write-Host "Windows run entry validation passed."
|
||||
@@ -47,13 +54,7 @@ try {
|
||||
}
|
||||
exit $ApplicationExitCode
|
||||
}
|
||||
|
||||
$Python = $null
|
||||
if (Test-ProjectPython -Candidate $ProjectPython) {
|
||||
$Python = $ProjectPython
|
||||
Write-Host "Using existing project environment: $ProjectPython"
|
||||
}
|
||||
else {
|
||||
elseif (Test-Path -LiteralPath $SourceEntry -PathType Leaf) {
|
||||
$Uv = Find-Uv
|
||||
if (-not $Uv) {
|
||||
throw "No packaged application or usable .venv was found, and uv is not installed."
|
||||
@@ -108,6 +109,9 @@ try {
|
||||
}
|
||||
$Python = $ProjectPython
|
||||
}
|
||||
else {
|
||||
throw "No source entry or packaged application was found."
|
||||
}
|
||||
|
||||
if ($ValidateOnly) {
|
||||
Write-Host "Windows run entry validation passed."
|
||||
|
||||
@@ -1004,6 +1004,8 @@ class ApplicationController(QObject):
|
||||
preview_height = min(380, max(320, round(preview_width * 0.66)))
|
||||
margin = 18
|
||||
|
||||
if callable(getattr(video_window, "setProperty", None)):
|
||||
video_window.setProperty("doctorSkipOpenGeometry", True)
|
||||
video_window.showNormal()
|
||||
video_window.setMinimumSize(440, 300)
|
||||
video_window.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
|
||||
@@ -1058,6 +1060,8 @@ class ApplicationController(QObject):
|
||||
window.raise_()
|
||||
if activate:
|
||||
window.activateWindow()
|
||||
if callable(getattr(window, "setProperty", None)):
|
||||
window.setProperty("doctorSkipOpenGeometry", False)
|
||||
except (AttributeError, RuntimeError):
|
||||
return
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from ..core.appointment_modes import APPOINTMENT_MODES
|
||||
from .theme import main_window_target_rect
|
||||
from .widgets import (
|
||||
display_text,
|
||||
first_value,
|
||||
@@ -1766,6 +1767,11 @@ class AppointmentDrawer(QDialog):
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def _sync_to_owner(self) -> None:
|
||||
target = main_window_target_rect(self)
|
||||
if target is not None:
|
||||
self.setGeometry(target)
|
||||
self._update_drawer_width()
|
||||
return
|
||||
if self._owner is None:
|
||||
self._update_drawer_width()
|
||||
return
|
||||
|
||||
@@ -176,8 +176,8 @@ QDialog#AiConsultDialog QTabBar#AiConsultTabs {
|
||||
}
|
||||
QDialog#AiConsultDialog QTabBar#AiConsultTabs::tab {
|
||||
min-height: 36px;
|
||||
padding: 0 16px;
|
||||
margin-right: 4px;
|
||||
padding: 0 7px;
|
||||
margin-right: 0;
|
||||
color: #606163;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
@@ -189,6 +189,18 @@ QDialog#AiConsultDialog QTabBar#AiConsultTabs::tab:selected {
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid #4F63D9;
|
||||
}
|
||||
QDialog#AiConsultDialog QPushButton#AiConsultChatFilter {
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
color: #4F63D9;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E1E5F4;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
QDialog#AiConsultDialog QPushButton#AiConsultChatFilter:hover {
|
||||
background-color: #EEF1FA;
|
||||
}
|
||||
QDialog#AiConsultDialog QFrame#AiConsultChatPane,
|
||||
QDialog#AiConsultDialog QFrame#AiConsultSide,
|
||||
QDialog#AiConsultDialog QFrame#AiConsultRecordPane {
|
||||
@@ -637,6 +649,13 @@ QDialog#AiConsultDialog QLabel#AiConsultTag[level="warn"] {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QDialog#AiConsultDialog QLabel#AiConsultTag[level="unknown"] {
|
||||
color: #6A6B6D;
|
||||
background-color: #F0F0F0;
|
||||
border-radius: 8px;
|
||||
padding: 2px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
QDialog#AiConsultDialog QPushButton#AiConsultChipButton {
|
||||
min-height: 28px;
|
||||
max-height: 30px;
|
||||
@@ -3463,6 +3482,7 @@ class AiConsultDialog(QDialog):
|
||||
# done 事件是否到达。用于区分“模型没内容”和“连接直接断了”。
|
||||
self._stream_completed = False
|
||||
self._follow_chat = True
|
||||
self._chat_filter = "all"
|
||||
self._flush_timer = QTimer(self)
|
||||
self._flush_timer.setSingleShot(True)
|
||||
self._flush_timer.setInterval(40)
|
||||
@@ -3513,6 +3533,7 @@ class AiConsultDialog(QDialog):
|
||||
self._generation += 1
|
||||
self._image_generation += 1
|
||||
self._clear_chat()
|
||||
self._set_chat_filter("all")
|
||||
# The AI context envelope depends on workspace data that has not yet
|
||||
# been fetched, so reset its cache eagerly instead of letting it
|
||||
# linger from a previous patient session.
|
||||
@@ -3545,6 +3566,24 @@ class AiConsultDialog(QDialog):
|
||||
layout = QVBoxLayout(shell)
|
||||
layout.setContentsMargins(18, 14, 18, 14)
|
||||
layout.setSpacing(12)
|
||||
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
splitter.setChildrenCollapsible(False)
|
||||
splitter.addWidget(self._build_center())
|
||||
splitter.addWidget(self._build_right_pane())
|
||||
splitter.setStretchFactor(0, 1)
|
||||
splitter.setStretchFactor(1, 0)
|
||||
splitter.setSizes([920, 460])
|
||||
layout.addWidget(splitter, 1)
|
||||
|
||||
def _build_right_pane(self) -> QWidget:
|
||||
host = QWidget()
|
||||
host.setObjectName("AiConsultRightPane")
|
||||
host.setMinimumWidth(360)
|
||||
host.setMaximumWidth(480)
|
||||
layout = QVBoxLayout(host)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(10)
|
||||
layout.addLayout(self._build_crumb())
|
||||
layout.addWidget(self._build_hero())
|
||||
self.tabs = QTabBar()
|
||||
@@ -3553,15 +3592,31 @@ class AiConsultDialog(QDialog):
|
||||
self.tabs.addTab(title)
|
||||
self.tabs.currentChanged.connect(self._tab_changed)
|
||||
layout.addWidget(self.tabs)
|
||||
layout.addWidget(self._build_side(), 1)
|
||||
return host
|
||||
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
splitter.setChildrenCollapsible(False)
|
||||
splitter.addWidget(self._build_center())
|
||||
splitter.addWidget(self._build_side())
|
||||
splitter.setStretchFactor(0, 1)
|
||||
splitter.setStretchFactor(1, 0)
|
||||
splitter.setSizes([920, 400])
|
||||
layout.addWidget(splitter, 1)
|
||||
def _build_chat_filter(self) -> QPushButton:
|
||||
self.chat_filter_button = QPushButton("筛选")
|
||||
self.chat_filter_button.setObjectName("AiConsultChatFilter")
|
||||
self.chat_filter_button.setAccessibleName("筛选问诊对话")
|
||||
self.chat_filter_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.chat_filter_menu = QMenu(self.chat_filter_button)
|
||||
for value, label in (
|
||||
("all", "全部对话"),
|
||||
("patient", "患者消息"),
|
||||
("doctor", "医生消息"),
|
||||
("ai", "AI 助手消息"),
|
||||
("system", "系统事件"),
|
||||
):
|
||||
action = self.chat_filter_menu.addAction(label)
|
||||
action.setData(value)
|
||||
action.setCheckable(True)
|
||||
action.setChecked(value == "all")
|
||||
action.triggered.connect(
|
||||
lambda _checked=False, role=value: self._set_chat_filter(role)
|
||||
)
|
||||
self.chat_filter_button.setMenu(self.chat_filter_menu)
|
||||
return self.chat_filter_button
|
||||
|
||||
def _build_crumb(self) -> QHBoxLayout:
|
||||
row = QHBoxLayout()
|
||||
@@ -3577,6 +3632,7 @@ class AiConsultDialog(QDialog):
|
||||
current.setObjectName("AiConsultCrumbCurrent")
|
||||
row.addWidget(current)
|
||||
row.addStretch(1)
|
||||
row.addWidget(self._build_chat_filter())
|
||||
back = QPushButton("返回")
|
||||
back.setObjectName("AiConsultBack")
|
||||
back.clicked.connect(self.reject)
|
||||
@@ -3587,9 +3643,11 @@ class AiConsultDialog(QDialog):
|
||||
card = QFrame()
|
||||
card.setObjectName("AiConsultHero")
|
||||
_style_surface(card)
|
||||
row = QHBoxLayout(card)
|
||||
row.setContentsMargins(16, 14, 16, 14)
|
||||
row.setSpacing(14)
|
||||
body = QVBoxLayout(card)
|
||||
body.setContentsMargins(14, 12, 14, 12)
|
||||
body.setSpacing(8)
|
||||
row = QHBoxLayout()
|
||||
row.setSpacing(10)
|
||||
self.avatar = QLabel("患")
|
||||
self.avatar.setObjectName("AiConsultAvatar")
|
||||
self.avatar.setFixedSize(44, 44)
|
||||
@@ -3604,13 +3662,14 @@ class AiConsultDialog(QDialog):
|
||||
self.identity_label = QLabel("")
|
||||
self.identity_label.setObjectName("AiConsultMeta")
|
||||
info.addWidget(self.identity_label)
|
||||
self.chip_row = _FlowRow()
|
||||
info.addWidget(self.chip_row)
|
||||
row.addLayout(info, 1)
|
||||
full = QPushButton("查看完整资料")
|
||||
full.setObjectName("AiConsultFullRecord")
|
||||
full.clicked.connect(self._open_full_record)
|
||||
row.addWidget(full, 0, Qt.AlignmentFlag.AlignTop)
|
||||
body.addLayout(row)
|
||||
self.chip_row = _FlowRow()
|
||||
body.addWidget(self.chip_row)
|
||||
return card
|
||||
|
||||
def _build_center(self) -> QWidget:
|
||||
@@ -3815,7 +3874,6 @@ class AiConsultDialog(QDialog):
|
||||
side.setObjectName("AiConsultSide")
|
||||
_style_surface(side)
|
||||
side.setMinimumWidth(350)
|
||||
side.setMaximumWidth(420)
|
||||
outer = QVBoxLayout(side)
|
||||
outer.setContentsMargins(18, 16, 18, 16)
|
||||
outer.setSpacing(14)
|
||||
@@ -3918,6 +3976,32 @@ class AiConsultDialog(QDialog):
|
||||
|
||||
def _tab_changed(self, index: int) -> None:
|
||||
self.center_stack.setCurrentIndex(index)
|
||||
self.chat_filter_button.setVisible(index == 0)
|
||||
|
||||
def _set_chat_filter(self, role: str) -> None:
|
||||
self._chat_filter = role
|
||||
label = "全部对话"
|
||||
for action in self.chat_filter_menu.actions():
|
||||
selected = action.data() == role
|
||||
action.setChecked(selected)
|
||||
if selected:
|
||||
label = action.text()
|
||||
self.chat_filter_button.setText(
|
||||
"筛选" if role == "all" else f"筛选:{label}"
|
||||
)
|
||||
for index in range(self.chat_layout.count() - 1):
|
||||
widget = self.chat_layout.itemAt(index).widget()
|
||||
if widget is not None:
|
||||
widget.setVisible(self._chat_item_matches_filter(widget))
|
||||
|
||||
def _chat_item_matches_filter(self, widget: QWidget) -> bool:
|
||||
if self._chat_filter == "all":
|
||||
return True
|
||||
if isinstance(widget, _ChatEvent):
|
||||
return self._chat_filter == "system"
|
||||
if isinstance(widget, _ChatBubble):
|
||||
return widget._role == self._chat_filter
|
||||
return True
|
||||
|
||||
def _clear_chat(self) -> None:
|
||||
while self.chat_layout.count() > 1:
|
||||
@@ -3939,6 +4023,7 @@ class AiConsultDialog(QDialog):
|
||||
) -> _ChatBubble:
|
||||
bubble = _ChatBubble(role=role, text=text, time_text=time_text, extra=extra)
|
||||
self.chat_layout.insertWidget(self.chat_layout.count() - 1, bubble)
|
||||
bubble.setVisible(self._chat_item_matches_filter(bubble))
|
||||
if self._follow_chat:
|
||||
QTimer.singleShot(0, self._scroll_chat_to_bottom)
|
||||
return bubble
|
||||
@@ -3960,6 +4045,7 @@ class AiConsultDialog(QDialog):
|
||||
marker=marker,
|
||||
)
|
||||
self.chat_layout.insertWidget(self.chat_layout.count() - 1, event)
|
||||
event.setVisible(self._chat_item_matches_filter(event))
|
||||
if self._follow_chat:
|
||||
QTimer.singleShot(0, self._scroll_chat_to_bottom)
|
||||
return event
|
||||
@@ -4571,51 +4657,24 @@ class AiConsultDialog(QDialog):
|
||||
)
|
||||
top_risk = risk_rows[0] if risk_rows else {}
|
||||
risk_label = display_text(first_value(top_risk, "label"), "")
|
||||
risk_level = str(first_value(top_risk, "level", default="medium") or "medium")
|
||||
risk_level = str(first_value(top_risk, "level", default="unknown") or "unknown")
|
||||
if risk_level not in {"high", "medium", "low"}:
|
||||
risk_level = "medium"
|
||||
level_text = {"high": "高风险", "medium": "中风险", "low": "低风险"}[risk_level]
|
||||
diagnosis = _as_mapping(get_value(self._detail, "diagnosis", default=self._detail) or self._seed)
|
||||
appointment = _as_mapping(get_value(self._detail, "appointment", default={}))
|
||||
clinical = display_text(
|
||||
_pick(("clinical_diagnosis", "diagnosis_name"), diagnosis, appointment, self._seed),
|
||||
"",
|
||||
)
|
||||
complaint = display_text(_pick(("chief_complaint",), diagnosis, self._seed), "")
|
||||
glucose = _pick(("fasting_blood_sugar",), diagnosis, appointment, self._seed)
|
||||
glucose_number = _as_float(glucose)
|
||||
if not advice:
|
||||
advice = _join_meta(clinical, f"主诉 {complaint}" if complaint else "") or "结合现有病历评估证候与病情。"
|
||||
if not risk_label:
|
||||
if glucose_number is not None and glucose_number >= 7.0:
|
||||
risk_label, risk_level, level_text = "高血糖风险", "high", "高风险"
|
||||
elif glucose_number is not None and glucose_number >= 6.1:
|
||||
risk_label, risk_level, level_text = "血糖波动风险", "medium", "中风险"
|
||||
else:
|
||||
risk_label, risk_level, level_text = "需持续随访", "low", "低风险"
|
||||
if not treatment:
|
||||
treatment = "结合处方、依从性与最新检验结果评估是否需要调整用药。"
|
||||
markdown = _analysis_markdown(
|
||||
{
|
||||
"diagnosis_advice": advice,
|
||||
"treatment_advice": treatment,
|
||||
"risk_assessment": risk_rows
|
||||
or [{"label": risk_label, "level": risk_level}],
|
||||
}
|
||||
)
|
||||
risk_level = "unknown"
|
||||
level_text = {"high": "高风险", "medium": "中风险", "low": "低风险", "unknown": "未提供"}[risk_level]
|
||||
if not advice and not treatment and not risk_rows:
|
||||
self._set_insights(
|
||||
[("分析状态", "当前诊单暂无可用的 AI 分析报告,请重新分析后查看。", "未生成", "unknown")]
|
||||
)
|
||||
return
|
||||
markdown = _analysis_markdown(payload)
|
||||
if markdown:
|
||||
bubble = self._append_bubble("ai", markdown)
|
||||
bubble.finalize_clinical_analysis()
|
||||
glucose_tag, glucose_level = (
|
||||
("未达标", "warn")
|
||||
if "高血糖" in risk_label or risk_level == "high"
|
||||
else ("需关注", "low")
|
||||
)
|
||||
self._set_insights(
|
||||
[
|
||||
("血糖控制评估", advice, glucose_tag, glucose_level),
|
||||
("并发症风险评估", risk_label, level_text, risk_level),
|
||||
("用药合理性评估", treatment, "需关注", "low"),
|
||||
("血糖控制评估", advice or "报告未提供此项分析。", "已分析" if advice else "未提供", "low" if advice else "unknown"),
|
||||
("并发症风险评估", risk_label or "报告未列出风险项。", level_text, risk_level),
|
||||
("用药合理性评估", treatment or "报告未提供此项分析。", "已分析" if treatment else "未提供", "low" if treatment else "unknown"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ from ..diagnosis_media import (
|
||||
safe_image_sources,
|
||||
)
|
||||
from ..infinite_list import InfiniteList
|
||||
from ..theme import main_window_target_rect
|
||||
from ..widgets import (
|
||||
display_text,
|
||||
first_value,
|
||||
@@ -716,6 +717,11 @@ class OrderDetailDrawer(QDialog):
|
||||
QTimer.singleShot(0, self.sync_to_owner)
|
||||
|
||||
def sync_to_owner(self) -> None:
|
||||
target = main_window_target_rect(self)
|
||||
if target is not None:
|
||||
self.setGeometry(target)
|
||||
self.drawer_panel.setFixedWidth(max(1, round(target.width() * self.drawer_ratio)))
|
||||
return
|
||||
owner = self._owner
|
||||
if owner is None:
|
||||
self.resize(1152, 720)
|
||||
@@ -1836,6 +1842,10 @@ class DiagnosisDialog(QDialog):
|
||||
def _sync_host_geometry(self) -> None:
|
||||
if self._embedded:
|
||||
return
|
||||
target = main_window_target_rect(self)
|
||||
if target is not None:
|
||||
self.setGeometry(target)
|
||||
return
|
||||
owner = self._owner
|
||||
if owner is None:
|
||||
if self.width() < 760 or self.height() < 520:
|
||||
|
||||
@@ -93,6 +93,7 @@ from PySide6.QtWidgets import (
|
||||
from .. import icons
|
||||
from ..diagnosis_terms import shared_terms
|
||||
from ..infinite_list import InfiniteList
|
||||
from ..theme import main_window_target_rect
|
||||
from ..widgets import (
|
||||
MessageBanner,
|
||||
display_text,
|
||||
@@ -3306,6 +3307,17 @@ class PrescriptionEditorDialog(QDialog):
|
||||
self._fit_drawer_geometry()
|
||||
|
||||
def _fit_drawer_geometry(self) -> None:
|
||||
target = main_window_target_rect(self)
|
||||
if target is not None:
|
||||
width = target.width()
|
||||
self.drawer_surface.setMaximumWidth(max(self.DRAWER_WIDTH, width))
|
||||
self.setGeometry(target)
|
||||
if self.diagnosis_host.isVisible():
|
||||
editor_width = min(self.DRAWER_WIDTH, max(520, round(width * 0.55)))
|
||||
self.workspace_splitter.setSizes([max(300, width - editor_width - 1), editor_width])
|
||||
else:
|
||||
self.workspace_splitter.setSizes([0, width])
|
||||
return
|
||||
parent = self.parentWidget()
|
||||
if parent is None:
|
||||
return
|
||||
|
||||
@@ -885,6 +885,7 @@ class ShellWindow(QMainWindow):
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setProperty("doctorMainWindow", True)
|
||||
self.setWindowTitle("甄养堂 · 问诊中心")
|
||||
self.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
|
||||
# The ground paints its own rounded corner, so everything outside that
|
||||
|
||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
||||
from string import Template
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, QPointF, Qt
|
||||
from PySide6.QtCore import QEvent, QObject, QPointF, QRect, Qt
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QFont,
|
||||
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
|
||||
QScrollArea,
|
||||
QTextBrowser,
|
||||
QTextEdit,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from doctor_workstation.resources import resource_path
|
||||
@@ -1450,6 +1451,73 @@ def mark_business_dialog(dialog: QDialog, object_name: str | None = None) -> Non
|
||||
_refresh_widget_style(dialog)
|
||||
|
||||
|
||||
def main_window_target_rect(window: QWidget) -> QRect | None:
|
||||
"""Return a centered rectangle covering 85% of the visible workstation."""
|
||||
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
return None
|
||||
main = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in app.topLevelWidgets()
|
||||
if candidate is not window
|
||||
and candidate.property("doctorMainWindow")
|
||||
and candidate.isVisible()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if main is None:
|
||||
return None
|
||||
|
||||
main_frame = main.frameGeometry()
|
||||
target_width = max(1, round(main_frame.width() * 0.85))
|
||||
target_height = max(1, round(main_frame.height() * 0.85))
|
||||
return QRect(
|
||||
main_frame.center().x() - target_width // 2,
|
||||
main_frame.center().y() - target_height // 2,
|
||||
target_width,
|
||||
target_height,
|
||||
)
|
||||
|
||||
|
||||
def _size_new_window_to_main(window: QWidget) -> None:
|
||||
"""Open a business window at 85% of the visible workstation, centered."""
|
||||
|
||||
if not window.isWindow() or window.property("doctorMainWindow"):
|
||||
return
|
||||
if window.property("doctorSkipOpenGeometry"):
|
||||
return
|
||||
if (
|
||||
isinstance(window, (QMessageBox, QInputDialog, QFileDialog))
|
||||
or window.metaObject().className() == "QPrintDialog"
|
||||
):
|
||||
return
|
||||
if window.windowFlags() & Qt.WindowType.FramelessWindowHint:
|
||||
# Drawers have their own owner-sync routines and use the same target rect.
|
||||
return
|
||||
target = main_window_target_rect(window)
|
||||
if target is None:
|
||||
return
|
||||
|
||||
target_width = target.width()
|
||||
target_height = target.height()
|
||||
if window.minimumWidth() > target_width:
|
||||
window.setMinimumWidth(target_width)
|
||||
if window.minimumHeight() > target_height:
|
||||
window.setMinimumHeight(target_height)
|
||||
if window.maximumWidth() < target_width:
|
||||
window.setMaximumWidth(target_width)
|
||||
if window.maximumHeight() < target_height:
|
||||
window.setMaximumHeight(target_height)
|
||||
window.resize(target_width, target_height)
|
||||
frame = window.frameGeometry()
|
||||
window.move(
|
||||
target.center().x() - frame.width() // 2,
|
||||
target.center().y() - frame.height() // 2,
|
||||
)
|
||||
|
||||
|
||||
class _BusinessDialogStyleFilter(QObject):
|
||||
"""Style dynamically-created Qt dialogs just before they become visible."""
|
||||
|
||||
@@ -1490,6 +1558,7 @@ class _BusinessDialogStyleFilter(QObject):
|
||||
if not self._is_native_or_overlay(watched):
|
||||
allow_dialog_resize(watched)
|
||||
elif isinstance(watched, QDialog) and event_type == QEvent.Type.Show:
|
||||
_size_new_window_to_main(watched)
|
||||
for button_box in watched.findChildren(QDialogButtonBox):
|
||||
_polish_dialog_buttons(button_box)
|
||||
if not self._is_native_or_overlay(watched):
|
||||
@@ -1505,6 +1574,13 @@ class _BusinessDialogStyleFilter(QObject):
|
||||
watched.setMinimumWidth(max(watched.minimumWidth(), 460))
|
||||
elif isinstance(watched, QInputDialog):
|
||||
watched.setMinimumWidth(max(watched.minimumWidth(), 480))
|
||||
elif (
|
||||
isinstance(watched, QWidget)
|
||||
and event_type == QEvent.Type.Show
|
||||
and watched.isWindow()
|
||||
and watched.windowType() == Qt.WindowType.Window
|
||||
):
|
||||
_size_new_window_to_main(watched)
|
||||
return False
|
||||
|
||||
|
||||
@@ -1600,4 +1676,5 @@ __all__ = [
|
||||
"apply_theme",
|
||||
"crisp_pixmap",
|
||||
"mark_business_dialog",
|
||||
"main_window_target_rect",
|
||||
]
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -125,6 +125,56 @@ def test_ai_consult_dialog_matches_workspace_chrome(
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_header_and_chat_filter_move_to_right_pane_and_filter_message_roles(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(), PermissionSet(["tcm.diagnosis/aiAssistant"])
|
||||
)
|
||||
patient = dialog._append_bubble("patient", "患者提问")
|
||||
doctor = dialog._append_bubble("doctor", "医生回复")
|
||||
ai = dialog._append_bubble("ai", "智能分析")
|
||||
system = dialog._append_event(title="视频开始")
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
|
||||
right_pane = dialog.findChild(QWidget, "AiConsultRightPane")
|
||||
chat_pane = dialog.findChild(QWidget, "AiConsultChatPane")
|
||||
assert right_pane is not None and chat_pane is not None
|
||||
assert right_pane.mapTo(dialog, QPoint(0, 0)).x() >= chat_pane.mapTo(
|
||||
dialog, QPoint(chat_pane.width(), 0)
|
||||
).x()
|
||||
assert dialog.tabs.parentWidget() is right_pane
|
||||
assert dialog.chat_filter_button.parentWidget() is right_pane
|
||||
assert right_pane.findChild(QWidget, "AiConsultHero") is not None
|
||||
assert all(item.isVisible() for item in (patient, doctor, ai, system))
|
||||
|
||||
actions = {action.data(): action for action in dialog.chat_filter_menu.actions()}
|
||||
actions["doctor"].trigger()
|
||||
application.processEvents()
|
||||
assert doctor.isVisible()
|
||||
assert not patient.isVisible()
|
||||
assert not ai.isVisible()
|
||||
assert not system.isVisible()
|
||||
|
||||
new_doctor = dialog._append_bubble("doctor", "新的医生回复")
|
||||
new_patient = dialog._append_bubble("patient", "新的患者提问")
|
||||
application.processEvents()
|
||||
assert new_doctor.isVisible()
|
||||
assert not new_patient.isVisible()
|
||||
|
||||
actions["all"].trigger()
|
||||
application.processEvents()
|
||||
assert all(item.isVisible() for item in (patient, doctor, ai, system, new_doctor, new_patient))
|
||||
dialog.tabs.setCurrentIndex(1)
|
||||
application.processEvents()
|
||||
assert not dialog.chat_filter_button.isVisible()
|
||||
dialog.tabs.setCurrentIndex(0)
|
||||
application.processEvents()
|
||||
assert dialog.chat_filter_button.isVisible()
|
||||
dialog.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", [Qt.Key.Key_Return, Qt.Key.Key_Enter])
|
||||
def test_input_enter_submits_once_without_closing_dialog(
|
||||
application: QApplication,
|
||||
@@ -663,6 +713,29 @@ def test_ai_consult_sidebar_loads_patient_facts_and_reports(
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_missing_server_analysis_is_not_presented_as_ai_diagnosis(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
dialog = AiConsultDialog(
|
||||
DemoDoctorRepository(), PermissionSet(["tcm.diagnosis/aiAssistant"])
|
||||
)
|
||||
dialog._seed = {
|
||||
"clinical_diagnosis": "糖尿病",
|
||||
"fasting_blood_sugar": 11.2,
|
||||
}
|
||||
dialog._render_analysis({})
|
||||
dialog.show()
|
||||
application.processEvents()
|
||||
|
||||
titles = [
|
||||
widget.text()
|
||||
for widget in dialog.findChildren(QLabel, "AiConsultRecordTitle")
|
||||
]
|
||||
assert titles == ["分析状态"]
|
||||
assert not dialog.findChildren(QTextBrowser, "AiConsultBubbleText")
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_ai_consult_sidebar_survives_chat_archive_errors(
|
||||
application: QApplication,
|
||||
immediate_async: None,
|
||||
|
||||
@@ -53,6 +53,7 @@ def test_windows_one_click_entrypoints_and_release_pipeline() -> None:
|
||||
release_launcher = read("packaging/windows/start_release.bat")
|
||||
|
||||
assert run_script.index("$FrozenExecutable") < run_script.index("Find-Uv")
|
||||
assert run_script.index('Write-Host "Using current source tree:') < run_script.index('Write-Host "Using packaged application:')
|
||||
assert "& $Uv sync --frozen" in run_script
|
||||
assert '"UV_CACHE_DIR"' in run_script
|
||||
assert "$ProjectUvCache" in run_script
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QMainWindow
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services import DemoDoctorRepository
|
||||
from doctor_workstation.ui.appointment_drawer import AppointmentDrawer
|
||||
from doctor_workstation.ui.dialogs.ai_consult_picker import AiConsultTargetDialog
|
||||
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog, OrderDetailDrawer
|
||||
from doctor_workstation.ui.dialogs.prescription import PrescriptionEditorDialog
|
||||
from doctor_workstation.ui.shell import ShellWindow
|
||||
from doctor_workstation.ui.theme import _install_business_dialog_styling
|
||||
|
||||
|
||||
def test_new_dialog_and_im_window_use_85_percent_of_main_window() -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
_install_business_dialog_styling(app)
|
||||
main = QMainWindow()
|
||||
main.setProperty("doctorMainWindow", True)
|
||||
main.setGeometry(40, 60, 1200, 800)
|
||||
main.show()
|
||||
app.processEvents()
|
||||
|
||||
dialog = QDialog(main)
|
||||
dialog.setMinimumSize(1100, 700)
|
||||
im_window = QMainWindow()
|
||||
im_window.resize(1120, 760)
|
||||
try:
|
||||
for window in (dialog, im_window):
|
||||
window.show()
|
||||
app.processEvents()
|
||||
assert window.size().width() == round(main.frameGeometry().width() * 0.85)
|
||||
assert window.size().height() == round(main.frameGeometry().height() * 0.85)
|
||||
assert abs(window.frameGeometry().center().x() - main.frameGeometry().center().x()) <= 1
|
||||
assert abs(window.frameGeometry().center().y() - main.frameGeometry().center().y()) <= 1
|
||||
window.hide()
|
||||
main.resize(1400, 900)
|
||||
app.processEvents()
|
||||
for window in (dialog, im_window):
|
||||
window.show()
|
||||
app.processEvents()
|
||||
assert window.width() == round(main.frameGeometry().width() * 0.85)
|
||||
assert window.height() == round(main.frameGeometry().height() * 0.85)
|
||||
finally:
|
||||
im_window.close()
|
||||
dialog.close()
|
||||
main.close()
|
||||
|
||||
|
||||
def test_frameless_drawer_keeps_its_owner_layout() -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
_install_business_dialog_styling(app)
|
||||
main = QMainWindow()
|
||||
main.setProperty("doctorMainWindow", True)
|
||||
main.resize(1200, 800)
|
||||
main.show()
|
||||
app.processEvents()
|
||||
drawer = QDialog(main)
|
||||
drawer.setWindowFlag(Qt.WindowType.FramelessWindowHint, True)
|
||||
drawer.resize(500, 400)
|
||||
try:
|
||||
drawer.show()
|
||||
app.processEvents()
|
||||
assert drawer.size().width() == 500
|
||||
assert drawer.size().height() == 400
|
||||
finally:
|
||||
drawer.close()
|
||||
main.close()
|
||||
|
||||
|
||||
def test_modal_exec_uses_main_window_geometry() -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
_install_business_dialog_styling(app)
|
||||
main = QMainWindow()
|
||||
main.setProperty("doctorMainWindow", True)
|
||||
main.setGeometry(30, 50, 1200, 800)
|
||||
main.show()
|
||||
app.processEvents()
|
||||
dialog = QDialog(main)
|
||||
try:
|
||||
QTimer.singleShot(0, dialog.accept)
|
||||
dialog.exec()
|
||||
assert dialog.width() == round(main.frameGeometry().width() * 0.85)
|
||||
assert dialog.height() == round(main.frameGeometry().height() * 0.85)
|
||||
finally:
|
||||
dialog.close()
|
||||
main.close()
|
||||
|
||||
|
||||
def test_owner_synchronized_business_drawers_use_main_window_85_percent() -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
_install_business_dialog_styling(app)
|
||||
main = QMainWindow()
|
||||
main.setProperty("doctorMainWindow", True)
|
||||
main.setGeometry(30, 50, 1400, 900)
|
||||
main.show()
|
||||
app.processEvents()
|
||||
repository = DemoDoctorRepository()
|
||||
dialogs = [
|
||||
AppointmentDrawer({"id": 501, "diagnosis_id": 501}, repository, main, autoload=False),
|
||||
DiagnosisDialog(repository, main),
|
||||
OrderDetailDrawer(main),
|
||||
PrescriptionEditorDialog(repository, parent=main),
|
||||
]
|
||||
try:
|
||||
for dialog in dialogs:
|
||||
dialog.show()
|
||||
app.processEvents()
|
||||
app.processEvents()
|
||||
assert dialog.width() == round(main.frameGeometry().width() * 0.85)
|
||||
assert dialog.height() == round(main.frameGeometry().height() * 0.85)
|
||||
dialog.hide()
|
||||
finally:
|
||||
for dialog in dialogs:
|
||||
dialog.close()
|
||||
main.close()
|
||||
|
||||
|
||||
def test_maximized_workstation_opens_patient_picker_at_85_percent() -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
_install_business_dialog_styling(app)
|
||||
repository = DemoDoctorRepository()
|
||||
permissions = PermissionSet(["*"])
|
||||
main = ShellWindow(
|
||||
repository,
|
||||
{"user": {"name": "测试医生"}, "demo_mode": True},
|
||||
permissions,
|
||||
)
|
||||
main.showMaximized()
|
||||
app.processEvents()
|
||||
picker = AiConsultTargetDialog(repository, permissions, main)
|
||||
try:
|
||||
picker.show()
|
||||
app.processEvents()
|
||||
assert main.isMaximized()
|
||||
assert picker.width() == round(main.frameGeometry().width() * 0.85)
|
||||
assert picker.height() == round(main.frameGeometry().height() * 0.85)
|
||||
finally:
|
||||
picker.close()
|
||||
main.close()
|
||||
Reference in New Issue
Block a user