diff --git a/admin/src/views/first_visit/conversion/index.vue b/admin/src/views/first_visit/conversion/index.vue index 6f42661bd..246348e99 100644 --- a/admin/src/views/first_visit/conversion/index.vue +++ b/admin/src/views/first_visit/conversion/index.vue @@ -81,10 +81,10 @@ - + {{ item.name }} {{ item.customer_count }} 人 @@ -96,47 +96,47 @@
-
+
{{ metric.label }} {{ formatMetric(metric.key, metric.type) }} {{ metric.hint }}
-
-
-
-
-

{{ rankingSubject }}订单量占比排名

-

按{{ rankingSubject }}归属统计,排除取消、拒收及退款

-
- 单位:单 -
-
-
- {{ index + 1 }}{{ item.name }} -
- {{ formatNumber(item.value) }} 单{{ formatShare(item.value, totalOrderValue) }} -
-
- +
+
+
+
+

{{ rankingSubject }}订单量占比排名

+

按{{ rankingSubject }}归属统计,排除取消、拒收及退款

+
+ 单位:单 +
+
+
+ {{ index + 1 }}{{ item.name }} +
+ {{ formatNumber(item.value) }} 单{{ formatShare(item.value, totalOrderValue) }} +
+
+
-
-
-

{{ rankingSubject }}金额占比排名

-

按{{ rankingSubject }}归属统计,仅含未取消、未拒收且未退款的有效金额

-
- 单位:元 -
-
-
- {{ index + 1 }}{{ item.name }} -
- {{ formatMoney(item.value) }}{{ formatShare(item.value, totalAmountValue) }} -
-
+
+
+

{{ rankingSubject }}金额占比排名

+

按{{ rankingSubject }}归属统计,仅含未取消、未拒收且未退款的有效金额

+
+ 单位:元 +
+
+
+ {{ index + 1 }}{{ item.name }} +
+ {{ formatMoney(item.value) }}{{ formatShare(item.value, totalAmountValue) }} +
+
@@ -156,7 +156,7 @@ default-expand-all class="detail-table" > - + - - - - - - - + + + + + + + - + - + - + - + - + - + - + @@ -287,13 +287,18 @@ type MediaChannelOption = { tag_id?: string group_name?: string customer_count?: number + kind?: 'channel' | 'group' + search_label?: string } +const FINANCE_METRIC_KEYS = new Set(['account_cost', 'roi']) + const emptyDashboard = () => ({ meta: { - time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '', - scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '', - selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: '', ranking_kind: 'hidden' + time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '', + scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '', + selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: '', ranking_kind: 'hidden', + can_view_finance: false }, filters: { departments: [] as any[], @@ -347,30 +352,58 @@ const scopeDescription = computed(() => { if (dashboard.meta.selected_media_channel_name) parts.push(`渠道:${dashboard.meta.selected_media_channel_name}`) return parts.join(' · ') }) +const canViewFinance = computed(() => Boolean(dashboard.meta.can_view_finance)) +const visibleMetricCards = computed(() => + canViewFinance.value + ? metricCards + : metricCards.filter((card) => !FINANCE_METRIC_KEYS.has(card.key)) +) const mediaChannelGroups = computed(() => { - const groups = new Map() + }> = [] + const indexByName = new Map() + for (const channel of dashboard.filters.media_channels) { - const groupName = channel.group_name || '' - if (!groups.has(groupName)) { - groups.set(groupName, { group_name: groupName, customer_count: 0, channels: [] }) + if (channel.kind === 'group') continue + const groupName = String(channel.group_name || '').trim() + let groupIndex = indexByName.get(groupName) + if (groupIndex === undefined) { + groupIndex = groups.length + indexByName.set(groupName, groupIndex) + groups.push({ group_name: groupName, customer_count: 0, channels: [] }) } - const group = groups.get(groupName)! - group.channels.push(channel) + const group = groups[groupIndex] + const searchLabel = groupName !== '' && !channel.name.includes(groupName) + ? `${channel.name} ${groupName}` + : '' + group.channels.push(searchLabel === '' ? channel : { ...channel, search_label: searchLabel }) group.customer_count = Math.max(group.customer_count, Number(channel.customer_count || 0)) } - return Array.from(groups.values()) + + for (const group of groups) { + if (group.group_name === '') continue + const hasSameNameLeaf = group.channels.some((item) => item.name === group.group_name) + if (group.channels.length < 2 && hasSameNameLeaf) continue + group.channels.unshift({ + code: `group:${group.group_name}`, + name: `${group.group_name}(全部)`, + group_name: group.group_name, + customer_count: group.customer_count, + kind: 'group' + }) + } + return groups }) -const rankingKind = computed(() => dashboard.meta.ranking_kind || ( - Number(dashboard.meta.scope_value) === 4 ? 'hidden' : Number(dashboard.meta.scope_value) === 3 ? 'member' : 'group' -)) -const showRankings = computed(() => rankingKind.value !== 'hidden') -const rankingSubject = computed(() => rankingKind.value === 'member' ? '组内成员' : '小组') -const totalOrderValue = computed(() => dashboard.rankings.orders.reduce((total, item) => total + Number(item.value || 0), 0)) -const totalAmountValue = computed(() => dashboard.rankings.amounts.reduce((total, item) => total + Number(item.value || 0), 0)) +const rankingKind = computed(() => dashboard.meta.ranking_kind || ( + Number(dashboard.meta.scope_value) === 4 ? 'hidden' : Number(dashboard.meta.scope_value) === 3 ? 'member' : 'group' +)) +const showRankings = computed(() => rankingKind.value !== 'hidden') +const rankingSubject = computed(() => rankingKind.value === 'member' ? '组内成员' : '小组') +const totalOrderValue = computed(() => dashboard.rankings.orders.reduce((total, item) => total + Number(item.value || 0), 0)) +const totalAmountValue = computed(() => dashboard.rankings.amounts.reduce((total, item) => total + Number(item.value || 0), 0)) const targetChartOption = computed(() => ({ animationDuration: 450, color: ['#0f9185', '#2f78df'], @@ -452,14 +485,14 @@ function formatMoney(value: any) { return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` } -function formatPercent(value: any) { - return `${Number(value || 0).toFixed(1)}%` -} - -function formatShare(value: any, total: number) { - if (total <= 0) return '0.0%' - return `${(Number(value || 0) / total * 100).toFixed(1)}%` -} +function formatPercent(value: any) { + return `${Number(value || 0).toFixed(1)}%` +} + +function formatShare(value: any, total: number) { + if (total <= 0) return '0.0%' + return `${(Number(value || 0) / total * 100).toFixed(1)}%` +} function nullablePercent(value: any) { return value === null || value === undefined ? '未设置' : formatPercent(value) @@ -474,11 +507,11 @@ function compactNumber(value: number) { return String(Math.round(value)) } -function barWidth(value: any, total: number) { - const numericValue = Number(value || 0) - if (total <= 0 || numericValue <= 0) return '0%' - return `${Math.max(4, Math.min(100, numericValue / total * 100))}%` -} +function barWidth(value: any, total: number) { + const numericValue = Number(value || 0) + if (total <= 0 || numericValue <= 0) return '0%' + return `${Math.max(4, Math.min(100, numericValue / total * 100))}%` +} function progressValue(value: any) { return Math.max(0, Math.min(100, Number(value || 0))) @@ -488,29 +521,29 @@ onMounted(loadDashboard) diff --git a/app/.test-tmp-stream/uv-9a4cebfd4c2a144d.lock b/app/.test-tmp-stream/uv-9a4cebfd4c2a144d.lock new file mode 100644 index 000000000..e69de29bb diff --git a/app/artifacts/ai_consult_workspace_final/01_case_records.png b/app/artifacts/ai_consult_workspace_final/01_case_records.png new file mode 100644 index 000000000..834f7e805 Binary files /dev/null and b/app/artifacts/ai_consult_workspace_final/01_case_records.png differ diff --git a/app/artifacts/ai_consult_workspace_final/02_exam_tests.png b/app/artifacts/ai_consult_workspace_final/02_exam_tests.png new file mode 100644 index 000000000..77476f5f6 Binary files /dev/null and b/app/artifacts/ai_consult_workspace_final/02_exam_tests.png differ diff --git a/app/artifacts/ai_consult_workspace_final/03_prescriptions.png b/app/artifacts/ai_consult_workspace_final/03_prescriptions.png new file mode 100644 index 000000000..f0b5dbc1c Binary files /dev/null and b/app/artifacts/ai_consult_workspace_final/03_prescriptions.png differ diff --git a/app/artifacts/ai_consult_workspace_final/04_health_profile.png b/app/artifacts/ai_consult_workspace_final/04_health_profile.png new file mode 100644 index 000000000..c7d59c5cd Binary files /dev/null and b/app/artifacts/ai_consult_workspace_final/04_health_profile.png differ diff --git a/app/artifacts/ai_consult_workspace_final/REPORT.md b/app/artifacts/ai_consult_workspace_final/REPORT.md new file mode 100644 index 000000000..cb0edbf5f --- /dev/null +++ b/app/artifacts/ai_consult_workspace_final/REPORT.md @@ -0,0 +1,39 @@ +# AiConsultDialog 诊单工作区最终验收 + +日期:2026-08-18 +范围:诊单 `501`;复用 `tests/test_ai_consult_workspace_ui.py` 的 `WorkspaceRepository` 与即时异步执行方式;只读验收,未修改业务源码或测试。 + +## 结论 + +内容、控件数量、安全过滤与横向几何均通过。四个页签均有非空中文内容,没有出现 `['...']`、`{'...'}` 等 Python 集合 repr。默认窗口 `1280 × 820` 和最小窗口 `1080 × 680` 下,四页签横向滚动最大值均为 `0`,内容宽度等于视口宽度,未发现后代控件越界。 + +存在 1 类状态提示问题:测试仓储故意返回外诊单数据时,检查检验页和健康档案页已正确过滤这些数据,但把“已拒绝 1 条归属其他诊单的数据”呈现为红色 `error` 状态并显示“重新加载”。有效内容仍完整可见,安全边界也生效;建议后续改成 warning/info 提示,避免被误解为数据加载失败。 + +## 截图 + +- `01_case_records.png`:病历资料 +- `02_exam_tests.png`:检查检验 +- `03_prescriptions.png`:处方记录 +- `04_health_profile.png`:健康档案 + +截图均为 `1280 × 820`,按真实应用启动路径调用全局主题,字体为 `Microsoft YaHei UI`。 + +## 内容与控件核验 + +| 页签 | 核验结果 | +| --- | --- | +| 病历资料 | 文本 969 字符;`AiConsultCaseGrid` 1 个;中文集合已人类可读化;Python repr 标记 0 个。 | +| 检查检验 | 文本 173 字符;时间线 1 个;舌苔缩略图 1 个;附件按钮 3 个,其中 PDF 报告 2 个;安全 HTTP(S) 按钮可用,本地 `file:` 附件按钮禁用。 | +| 处方记录 | 文本 316 字符;处方卡 3 张;详情按钮 3 个;处方 ID 为 `5011 / 5012 / 5013`。 | +| 健康档案 | 文本 443 字符;患者信息网格 1 个;本诊单健康概览 1 个;血糖/血压、饮食、运动三类跟踪记录均存在;手机号和身份证号已脱敏。 | + +## 几何 + +- `1280 × 820`:记录面板约 `876 × 586`;有纵向滚动条时视口/内容宽度均为 `824`,检查检验页为 `834`;所有页签 `hbar maximum = 0`、后代越界量 `0`。 +- `1080 × 680`:记录面板 `676 × 446`;有纵向滚动条时视口/内容宽度均为 `624`,检查检验页为 `634`;所有页签 `hbar maximum = 0`、后代越界量 `0`。 +- 长内容通过纵向滚动呈现:病历、处方、健康档案的默认窗口纵向最大值分别为 `1064 / 215 / 455`;未发现横向裁切。 + +## Smoke + +执行:`uv run --offline pytest -q tests/test_ai_consult_workspace_ui.py` +结果:`11 passed`。仅有 pytest 缓存目录无写权限警告,不影响测试结果。 diff --git a/app/artifacts/diagnosis_visual/diagnosis_1366x768.png b/app/artifacts/diagnosis_visual/diagnosis_1366x768.png new file mode 100644 index 000000000..0af9d29ef Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_1366x768.png differ diff --git a/app/artifacts/diagnosis_visual/diagnosis_1710x920.png b/app/artifacts/diagnosis_visual/diagnosis_1710x920.png new file mode 100644 index 000000000..6a5389a07 Binary files /dev/null and b/app/artifacts/diagnosis_visual/diagnosis_1710x920.png differ diff --git a/app/artifacts/patient_ai_report_layout/patient_ai_report_720x560.png b/app/artifacts/patient_ai_report_layout/patient_ai_report_720x560.png new file mode 100644 index 000000000..dd3e91322 Binary files /dev/null and b/app/artifacts/patient_ai_report_layout/patient_ai_report_720x560.png differ diff --git a/app/artifacts/patient_ai_report_layout/patient_ai_report_920x760.png b/app/artifacts/patient_ai_report_layout/patient_ai_report_920x760.png new file mode 100644 index 000000000..ffcb37a00 Binary files /dev/null and b/app/artifacts/patient_ai_report_layout/patient_ai_report_920x760.png differ diff --git a/app/artifacts/patient_appointment_density/appointments_1024x640.png b/app/artifacts/patient_appointment_density/appointments_1024x640.png new file mode 100644 index 000000000..48721e651 Binary files /dev/null and b/app/artifacts/patient_appointment_density/appointments_1024x640.png differ diff --git a/app/artifacts/patient_appointment_density/appointments_1366x768.png b/app/artifacts/patient_appointment_density/appointments_1366x768.png new file mode 100644 index 000000000..69243b17e Binary files /dev/null and b/app/artifacts/patient_appointment_density/appointments_1366x768.png differ diff --git a/app/artifacts/patient_appointment_density/patients_1024x640.png b/app/artifacts/patient_appointment_density/patients_1024x640.png new file mode 100644 index 000000000..3671867c4 Binary files /dev/null and b/app/artifacts/patient_appointment_density/patients_1024x640.png differ diff --git a/app/artifacts/patient_appointment_density/patients_1366x768.png b/app/artifacts/patient_appointment_density/patients_1366x768.png new file mode 100644 index 000000000..9d0cbf0d8 Binary files /dev/null and b/app/artifacts/patient_appointment_density/patients_1366x768.png differ diff --git a/app/artifacts/prescription_list_density/prescription_library_1366x768.png b/app/artifacts/prescription_list_density/prescription_library_1366x768.png new file mode 100644 index 000000000..ad51e5ba5 Binary files /dev/null and b/app/artifacts/prescription_list_density/prescription_library_1366x768.png differ diff --git a/app/artifacts/prescription_list_density/prescription_library_1710x920.png b/app/artifacts/prescription_list_density/prescription_library_1710x920.png new file mode 100644 index 000000000..2e3f880de Binary files /dev/null and b/app/artifacts/prescription_list_density/prescription_library_1710x920.png differ diff --git a/app/artifacts/prescription_list_density/prescriptions_1366x768.png b/app/artifacts/prescription_list_density/prescriptions_1366x768.png new file mode 100644 index 000000000..76ab87598 Binary files /dev/null and b/app/artifacts/prescription_list_density/prescriptions_1366x768.png differ diff --git a/app/artifacts/prescription_list_density/prescriptions_1710x920.png b/app/artifacts/prescription_list_density/prescriptions_1710x920.png new file mode 100644 index 000000000..7645e0d7a Binary files /dev/null and b/app/artifacts/prescription_list_density/prescriptions_1710x920.png differ diff --git a/app/artifacts/reception_daily_records/reception_daily_records_1710x920.png b/app/artifacts/reception_daily_records/reception_daily_records_1710x920.png new file mode 100644 index 000000000..d262c23a9 Binary files /dev/null and b/app/artifacts/reception_daily_records/reception_daily_records_1710x920.png differ diff --git a/app/scripts/render_diagnosis_visual.py b/app/scripts/render_diagnosis_visual.py index 82acf8b7b..2d67a3a6f 100644 --- a/app/scripts/render_diagnosis_visual.py +++ b/app/scripts/render_diagnosis_visual.py @@ -31,6 +31,8 @@ class _ScreenshotDiagnosisDialog(QWidget): consultations_module.DiagnosisDialog = _ScreenshotDiagnosisDialog +DENSITY_SIZES = ((1366, 768), (1710, 920)) + def _row(identifier: int, variant: int) -> dict[str, Any]: common: dict[str, Any] = { @@ -335,7 +337,7 @@ def _save_with_payment_qr( return path -def render() -> list[Path]: +def _application() -> QApplication: app = QApplication.instance() or QApplication([]) # The offscreen Windows plugin does not enumerate system fonts. Register # the same CJK face used by the production QSS when it is available. @@ -345,12 +347,35 @@ def render() -> list[Path]: families = QFontDatabase.applicationFontFamilies(font_id) if families: app.setFont(QFont(families[0], 9)) + return app + + +def render_density() -> list[Path]: + """Render only the two desktop-density acceptance sizes.""" + + app = _application() root = Path(__file__).resolve().parents[1] output = root / "artifacts" / "diagnosis_visual" output.mkdir(parents=True, exist_ok=True) paths: list[Path] = [] repository = ScreenshotRepository() - for width, height in ((1024, 640), (1440, 900)): + for width, height in DENSITY_SIZES: + page = _new_page(app, repository, width, height) + path = output / f"diagnosis_{width}x{height}.png" + paths.append(_save(page, path)) + page.close() + app.processEvents() + return paths + + +def render() -> list[Path]: + app = _application() + root = Path(__file__).resolve().parents[1] + output = root / "artifacts" / "diagnosis_visual" + output.mkdir(parents=True, exist_ok=True) + paths: list[Path] = [] + repository = ScreenshotRepository() + for width, height in ((1024, 640), *DENSITY_SIZES, (1440, 900)): page = _new_page(app, repository, width, height) path = output / f"diagnosis_{width}x{height}.png" paths.append(_save(page, path)) diff --git a/app/scripts/render_patient_ai_report_layout.py b/app/scripts/render_patient_ai_report_layout.py new file mode 100644 index 000000000..fac0dc514 --- /dev/null +++ b/app/scripts/render_patient_ai_report_layout.py @@ -0,0 +1,119 @@ +"""Render patient-level AI report layout regressions with the offscreen Qt backend.""" + +from __future__ import annotations + +import os +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtGui import QFontDatabase +from PySide6.QtWidgets import QApplication, QLabel, QScrollArea + +from doctor_workstation.ui.pages.reception import _ReceptionAiAnalysisDialog + + +def _report_payload(model_key: str) -> dict[str, object]: + model_label = "OpenAI" if model_key == "openai" else "千问" + return { + "model_key": model_key, + "model_label": model_label, + "generated_at": "2026-08-17 10:20:00", + "diagnosis_advice": [ + "2型糖尿病,HbA1c 7.5%,近期空腹血糖仍有波动。", + "建议:1. 监测空腹血糖 2. 记录餐后2小时血糖 3. 复核低血糖症状", + r"保留患者原始报告结构。\n结合复诊记录动态调整随访频率。", + ], + "risk_assessment": [ + {"label": "低血糖", "level": "high"}, + {"label": "依从性风险", "level": "medium"}, + {"label": "并发症筛查延误风险", "level": "low"}, + {"label": "复诊中断风险", "level": "medium"}, + { + "label": "肾功能变化可能影响二甲双胍方案,需要结合复查结果持续评估。", + "level": "high", + }, + {"label": "饮食波动风险", "level": "low"}, + ], + "treatment_advice": [ + r"二甲双胍 0.5g,每日2次。\n复查肾功能后再评估剂量。", + "继续糖尿病饮食教育,并记录运动后的血糖变化。", + "如出现心悸、出汗或意识异常,及时复测血糖并按流程处置。", + ], + } + + +def _render( + app: QApplication, + output: Path, + *, + width: int, + height: int, +) -> None: + histories = { + "qwen": [_report_payload("qwen")], + "openai": [_report_payload("openai")], + } + dialog = _ReceptionAiAnalysisDialog(histories, preferred_model="qwen") + dialog.resize(width, height) + dialog.show() + for _index in range(3): + app.processEvents() + + pixmap = dialog.grab() + if pixmap.width() != width or pixmap.height() != height: + raise RuntimeError( + f"unexpected render size: {pixmap.width()}x{pixmap.height()} " + f"(expected {width}x{height})" + ) + output.parent.mkdir(parents=True, exist_ok=True) + if not pixmap.save(str(output), "PNG"): + raise RuntimeError(f"failed to save {output}") + + scrolls = dialog.findChildren(QScrollArea) + risks = [ + label + for label in dialog.findChildren(QLabel) + if label.property("dialogAiRisk") + ] + print( + "PATIENT_AI_LAYOUT", + f"{width}x{height}", + f"scrolls={len(scrolls)}", + f"horizontal_max={dialog.scroll_area.horizontalScrollBar().maximum()}", + f"risk_rows={len({label.y() for label in risks})}", + f"body_height={dialog.scroll_area.widget().height()}", + ) + print(output) + dialog.close() + app.processEvents() + + +def main() -> int: + app = QApplication.instance() or QApplication([]) + font_path = Path(r"C:\Windows\Fonts\msyh.ttc") + if font_path.is_file(): + QFontDatabase.addApplicationFont(str(font_path)) + + output_dir = ( + Path(__file__).resolve().parents[1] + / "artifacts" + / "patient_ai_report_layout" + ) + _render( + app, + output_dir / "patient_ai_report_920x760.png", + width=920, + height=760, + ) + _render( + app, + output_dir / "patient_ai_report_720x560.png", + width=720, + height=560, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/app/scripts/render_patient_appointment_density.py b/app/scripts/render_patient_appointment_density.py new file mode 100644 index 000000000..d4fd97486 --- /dev/null +++ b/app/scripts/render_patient_appointment_density.py @@ -0,0 +1,182 @@ +"""Render patient and appointment density gates in the real desktop shell.""" + +from __future__ import annotations + +import os +from datetime import date +from pathlib import Path +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtCore import QThreadPool +from PySide6.QtGui import QFont, QFontDatabase +from PySide6.QtWidgets import QApplication + +from doctor_workstation.services import DemoDoctorRepository +from doctor_workstation.ui import ShellWindow, apply_theme + + +def _drain(application: QApplication) -> None: + QThreadPool.globalInstance().waitForDone(5_000) + for _index in range(8): + application.processEvents() + + +def _patient_rows() -> list[dict[str, Any]]: + names = ("林晓岚", "赵明远", "吴诗雨", "周安然", "程静", "许清和") + rows: list[dict[str, Any]] = [] + for index in range(15): + rows.append( + { + "id": 101 + index, + "diagnosis_id": 501 + index, + "patient_id": 301 + index, + "patient_name": names[index % len(names)], + "gender": 2 if index % 2 == 0 else 1, + "age": 34 + index, + "phone_masked": f"138****{1200 + index:04d}", + "assistant_id": 8, + "assistant_name": "周医助", + "appointment_id": 701 + index, + "appointment_status": 1, + "appointment_doctor_name": "陈医生", + "appointment_time_text": f"2026-08-{17 + index % 3:02d} {9 + index % 7:02d}:00", + "revisit_count": index % 4, + "confirmation_text": "已确认" if index % 2 == 0 else "待确认", + "diagnosis_date_text": "第 2 次复诊" if index % 3 else "初诊", + "has_id_card": index % 4 != 0, + } + ) + return rows + + +def _appointment_rows() -> list[dict[str, Any]]: + names = ("林晓岚", "赵明远", "吴诗雨", "周安然", "程静", "许清和") + today = date.today().isoformat() + rows: list[dict[str, Any]] = [] + for index in range(8): + rows.append( + { + "id": 801 + index, + "diagnosis_id": 901 + index, + "patient_id": 401 + index, + "patient_name": names[index % len(names)], + "patient_phone": f"1380013{8000 + index}", + "gender": 2 if index % 2 == 0 else 1, + "age": 38 + index, + "doctor_name": "陈医生", + "assistant_id": 8, + "assistant_name": "周医助", + "appointment_date": today, + "appointment_time": f"{9 + index:02d}:00", + "channel_name": "线上复诊", + "diagnosis_confirmed": index % 2 == 0, + "has_prescription": index % 3 == 0, + "status": 1, + "status_desc": "已挂号", + "revisit_time": "复诊" if index % 2 else "初诊", + "unserved_days": index, + } + ) + return rows + + +def render() -> list[Path]: + application = QApplication.instance() or QApplication([]) + apply_theme(application) + font_path = Path("C:/Windows/Fonts/msyh.ttc") + if font_path.is_file(): + font_id = QFontDatabase.addApplicationFont(str(font_path)) + families = QFontDatabase.applicationFontFamilies(font_id) + if families: + application.setFont(QFont(families[0], 9)) + + output = Path(__file__).resolve().parents[1] / "artifacts" / "patient_appointment_density" + output.mkdir(parents=True, exist_ok=True) + paths: list[Path] = [] + + for width, height in ((1366, 768), (1024, 640)): + repository = DemoDoctorRepository() + session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD) + shell = ShellWindow( + repository, + {"session": session, "demo_mode": True}, + permissions=session.permissions, + ) + shell.resize(width, height) + shell.show() + _drain(application) + + if not shell.navigate("patients"): + raise RuntimeError("patients navigation is unavailable") + _drain(application) + patients = shell.pages["patients"] + patient_rows = _patient_rows() + patients.patient_workspace._apply_result( + { + "lists": patient_rows, + "count": len(patient_rows), + "extend": { + "scope": {"label": "当前医生与部门"}, + "summary": {"today": 6, "tomorrow": 5, "day_after": 4}, + }, + }, + patients.patient_workspace._generation, + ) + application.processEvents() + patient_slots = patients.patient_workspace.table.viewport().height() // 40 + if width == 1366 and patient_slots < 6: + raise RuntimeError(f"patient table only exposes {patient_slots} ordinary rows") + patient_path = output / f"patients_{width}x{height}.png" + if not shell.grab().save(str(patient_path), "PNG"): + raise RuntimeError(f"failed to save {patient_path}") + paths.append(patient_path) + + if not shell.navigate("appointments"): + raise RuntimeError("appointments navigation is unavailable") + _drain(application) + appointments = shell.pages["appointments"] + appointments.poll_timer.stop() + appointment_rows = _appointment_rows() + appointments._loaded( + { + "lists": appointment_rows, + "count": len(appointment_rows), + "extend": { + "status_count": {"1": len(appointment_rows), "3": 0}, + "unassigned_count": 0, + }, + }, + appointments._generation, + False, + ) + application.processEvents() + row_heights = [ + appointments.table.rowHeight(index) + for index in range(appointments.table.rowCount()) + ] + appointment_slots = appointments.table.viewport().height() // max(row_heights) + if width == 1366 and appointment_slots < 4: + raise RuntimeError( + f"appointment table only exposes {appointment_slots} ordinary rows" + ) + if width == 1024 and not appointments.video_panel.isHidden(): + raise RuntimeError("narrow appointment viewport did not collapse the video panel") + appointment_path = output / f"appointments_{width}x{height}.png" + if not shell.grab().save(str(appointment_path), "PNG"): + raise RuntimeError(f"failed to save {appointment_path}") + paths.append(appointment_path) + print( + f"{width}x{height}: patient_slots={patient_slots}, " + f"appointment_slots={appointment_slots}, video_hidden={appointments.video_panel.isHidden()}" + ) + shell.close() + application.processEvents() + + return paths + + +if __name__ == "__main__": + for rendered in render(): + print(rendered) diff --git a/app/scripts/render_prescription_list_density.py b/app/scripts/render_prescription_list_density.py new file mode 100644 index 000000000..5e734e89e --- /dev/null +++ b/app/scripts/render_prescription_list_density.py @@ -0,0 +1,180 @@ +"""Render deterministic prescription-list density acceptance screenshots.""" + +from __future__ import annotations + +import os +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtGui import QFont, QFontDatabase +from PySide6.QtWidgets import QApplication + +from doctor_workstation.ui.pages import prescription_library as library_module +from doctor_workstation.ui.pages import prescriptions as prescriptions_module +from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage +from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage +from doctor_workstation.ui.theme import apply_theme + +DENSITY_SIZES = ((1366, 768), (1710, 920)) + + +def _run_immediately( + function: Any, + *args: Any, + on_success: Any = None, + on_error: Any = None, + on_finished: Any = None, + **kwargs: Any, +) -> object: + try: + result = function(*args, **kwargs) + except Exception as error: + if on_error is not None: + on_error(error) + else: + if on_success is not None: + on_success(result) + finally: + if on_finished is not None: + on_finished() + return object() + + +prescriptions_module.run_async = _run_immediately +library_module.run_async = _run_immediately + + +def _issued_row(index: int) -> dict[str, Any]: + return { + "id": 1000 + index, + "sn": f"CF-202608-{1000 + index}", + "prescription_type": "汤剂", + "is_system_auto": index % 2, + "patient_name": ("林晓岚", "周明远", "许安然")[index % 3], + "gender": 2 if index % 2 else 1, + "age": 29 + index, + "audit_status": index % 3, + "void_status": 0, + "has_prescription_order": index % 2, + "creator_id": 7, + "doctor_name": "陈医生", + "assistant_name": "赵医助", + "create_time": f"2026-08-{(index % 9) + 10:02d} 09:30:00", + "herbs": [{"name": "黄芪", "dosage": 15}], + } + + +def _library_row(index: int) -> dict[str, Any]: + return { + "id": 2000 + index, + "prescription_name": ("益气养阴方", "清热祛湿方", "滋阴调和方")[index % 3], + "formula_type": "主方" if index % 3 else "辅方", + "herbs": [ + {"name": "黄芪", "dosage": 15}, + {"name": "党参", "dosage": 12}, + ], + "efficacy": ("益气养阴", "清热祛湿", "滋阴补肾")[index % 3], + "is_public": index % 2, + "disable_edit": 0, + "creator_id": 7, + "creator_name": "陈医生", + "create_time": f"2026-08-{(index % 9) + 10:02d} 08:20:00", + } + + +class ScreenshotRepository: + def __init__(self) -> None: + self.issued_rows = [_issued_row(index) for index in range(15)] + self.library_rows = [_library_row(index) for index in range(15)] + + def list_diagnosis_doctors(self) -> list[dict[str, Any]]: + return [{"id": 7, "name": "陈医生"}, {"id": 8, "name": "孙医生"}] + + def list_prescriptions(self, **_filters: Any) -> dict[str, Any]: + return {"lists": self.issued_rows, "count": 44} + + def list_prescription_templates(self, **_filters: Any) -> dict[str, Any]: + return {"lists": self.library_rows, "count": 41} + + +def _application() -> QApplication: + app = QApplication.instance() or QApplication([]) + font_path = Path("C:/Windows/Fonts/msyh.ttc") + if font_path.is_file(): + font_id = QFontDatabase.addApplicationFont(str(font_path)) + families = QFontDatabase.applicationFontFamilies(font_id) + if families: + app.setFont(QFont(families[0], 9)) + apply_theme(app) + return app + + +def _settle(app: QApplication) -> None: + for _ in range(6): + app.processEvents() + + +def _visible_rows(page: PrescriptionsPage | PrescriptionLibraryPage) -> int: + viewport = page.table.viewport() + return sum( + 1 + for row in range(page.table.rowCount()) + if ( + (item := page.table.item(row, 0)) is not None + and (rect := page.table.visualItemRect(item)).isValid() + and rect.top() >= 0 + and rect.bottom() < viewport.height() + ) + ) + + +def _new_page( + kind: str, + repository: ScreenshotRepository, +) -> PrescriptionsPage | PrescriptionLibraryPage: + current_user = SimpleNamespace(id=7, name="陈医生", root=1, role_ids=[0]) + if kind == "prescriptions": + page: PrescriptionsPage | PrescriptionLibraryPage = PrescriptionsPage( + repository, {"*"}, current_user + ) + else: + page = PrescriptionLibraryPage(repository, {"*"}, current_user) + page.refresh() + return page + + +def render() -> list[Path]: + app = _application() + root = Path(__file__).resolve().parents[1] + output = root / "artifacts" / "prescription_list_density" + output.mkdir(parents=True, exist_ok=True) + repository = ScreenshotRepository() + paths: list[Path] = [] + + for kind in ("prescriptions", "prescription_library"): + for width, height in DENSITY_SIZES: + page = _new_page(kind, repository) + page.resize(width, height) + page.show() + _settle(app) + minimum_rows = 6 if height == 768 else 9 + visible_rows = _visible_rows(page) + if visible_rows < minimum_rows: + raise RuntimeError( + f"{kind} at {width}x{height} exposes only {visible_rows} full rows" + ) + path = output / f"{kind}_{width}x{height}.png" + if not page.grab().save(str(path), "PNG"): + raise RuntimeError(f"failed to save {path}") + paths.append(path) + page.close() + _settle(app) + return paths + + +if __name__ == "__main__": + for rendered in render(): + print(rendered) diff --git a/app/scripts/render_reception_daily_visual.py b/app/scripts/render_reception_daily_visual.py new file mode 100644 index 000000000..6ff6750c9 --- /dev/null +++ b/app/scripts/render_reception_daily_visual.py @@ -0,0 +1,69 @@ +"""Render the reception daily-record matrix for visual acceptance.""" + +from __future__ import annotations + +import os +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtCore import QThreadPool +from PySide6.QtGui import QFontDatabase +from PySide6.QtWidgets import QApplication + +from doctor_workstation.services.mock_repository import DemoDoctorRepository +from doctor_workstation.ui.shell import ShellWindow +from doctor_workstation.ui.theme import apply_theme + + +def main() -> int: + application = QApplication.instance() or QApplication([]) + apply_theme(application) + font_path = Path(r"C:\Windows\Fonts\msyh.ttc") + if font_path.is_file(): + QFontDatabase.addApplicationFont(str(font_path)) + + repository = DemoDoctorRepository() + session = repository.login("doctor", "doctor123") + window = ShellWindow(repository, session) + window.resize(1710, 920) + window.show() + if not window.navigate("reception"): + raise RuntimeError("reception navigation is unavailable") + + for _index in range(5): + application.processEvents() + QThreadPool.globalInstance().waitForDone(10_000) + page = window.pages["reception"] + daily_index = next( + index + for index in range(page.detail_tabs.count()) + if page.detail_tabs.tabText(index) == "日常记录" + ) + page.detail_tabs.setCurrentIndex(daily_index) + for _index in range(3): + application.processEvents() + QThreadPool.globalInstance().waitForDone(10_000) + + output = ( + Path(__file__).resolve().parents[1] + / "artifacts" + / "reception_daily_records" + / "reception_daily_records_1710x920.png" + ) + output.parent.mkdir(parents=True, exist_ok=True) + if not window.grab().save(str(output), "PNG"): + raise RuntimeError(f"failed to save {output}") + print(output) + print( + "DAILY_MATRIX", + page.daily_panel.matrix.rowCount(), + page.daily_panel.matrix.columnCount(), + page.daily_panel.current_range(), + ) + window.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/app/src/doctor_workstation/app.py b/app/src/doctor_workstation/app.py index c34fce140..b8045dd26 100644 --- a/app/src/doctor_workstation/app.py +++ b/app/src/doctor_workstation/app.py @@ -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 diff --git a/app/src/doctor_workstation/services/api_client.py b/app/src/doctor_workstation/services/api_client.py index 69c4541d7..39a48e3a1 100644 --- a/app/src/doctor_workstation/services/api_client.py +++ b/app/src/doctor_workstation/services/api_client.py @@ -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 diff --git a/app/src/doctor_workstation/services/repository.py b/app/src/doctor_workstation/services/repository.py index 3107a7a80..13a997b88 100644 --- a/app/src/doctor_workstation/services/repository.py +++ b/app/src/doctor_workstation/services/repository.py @@ -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 = { diff --git a/app/src/doctor_workstation/ui/diagnosis_drawer.py b/app/src/doctor_workstation/ui/diagnosis_drawer.py index fee6bbac6..280af056f 100644 --- a/app/src/doctor_workstation/ui/diagnosis_drawer.py +++ b/app/src/doctor_workstation/ui/diagnosis_drawer.py @@ -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.""" diff --git a/app/src/doctor_workstation/ui/diagnosis_index_widgets.py b/app/src/doctor_workstation/ui/diagnosis_index_widgets.py index 6e3abe34f..65acc17d4 100644 --- a/app/src/doctor_workstation/ui/diagnosis_index_widgets.py +++ b/app/src/doctor_workstation/ui/diagnosis_index_widgets.py @@ -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) diff --git a/app/src/doctor_workstation/ui/dialogs/__init__.py b/app/src/doctor_workstation/ui/dialogs/__init__.py index 99278dc55..19ef8f5e9 100644 --- a/app/src/doctor_workstation/ui/dialogs/__init__.py +++ b/app/src/doctor_workstation/ui/dialogs/__init__.py @@ -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", +] diff --git a/app/src/doctor_workstation/ui/dialogs/ai_consult.py b/app/src/doctor_workstation/ui/dialogs/ai_consult.py new file mode 100644 index 000000000..1e0b3721e --- /dev/null +++ b/app/src/doctor_workstation/ui/dialogs/ai_consult.py @@ -0,0 +1,3048 @@ +"""Full-page AI consultation workspace matching the product mock.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping, Sequence +from datetime import datetime +from threading import Event +from typing import Any +from urllib.parse import urlsplit + +from PySide6.QtCore import QObject, QPointF, QRunnable, QSize, Qt, QThreadPool, QTimer, Signal, Slot +from PySide6.QtGui import ( + QColor, + QIcon, + QPainter, + QPainterPath, + QPen, + QPixmap, + QPolygonF, +) +from PySide6.QtWidgets import ( + QDialog, + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QSizePolicy, + QSplitter, + QStackedWidget, + QTabBar, + QTextBrowser, + QVBoxLayout, + QWidget, +) + +from ..diagnosis_drawer import CaseGrid, _RemoteImageButton +from ..diagnosis_media import open_safe_http_url, safe_http_url +from ..theme import mark_business_dialog +from ..widgets import ( + display_text, + first_value, + friendly_error, + gender_text, + get_value, + has_permission, + invoke, + page_items, + run_async, + show_toast, +) +from .prescription_ai import can_use_diagnosis_ai_assistant, diagnosis_ai_task + +AI_CONSULT_QSS = """ +QDialog#AiConsultDialog { + color: #15224A; + background-color: #F4F7FD; + font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif; +} +QDialog#AiConsultDialog QScrollArea, +QDialog#AiConsultDialog QScrollArea > QWidget, +QDialog#AiConsultDialog QScrollArea > QWidget > QWidget, +QDialog#AiConsultDialog QSplitter, +QDialog#AiConsultDialog QStackedWidget { + background-color: transparent; + border: 0; +} +QDialog#AiConsultDialog QFrame#AiConsultShell { + background-color: #F4F7FD; + border: 0; +} +QDialog#AiConsultDialog QLabel#AiConsultCrumb { + color: #8A94B3; + background-color: transparent; + font-size: 12px; +} +QDialog#AiConsultDialog QLabel#AiConsultCrumbCurrent { + color: #3F4E75; + background-color: transparent; + font-size: 12px; + font-weight: 600; +} +QDialog#AiConsultDialog QPushButton#AiConsultBack { + min-width: 56px; + min-height: 30px; + max-height: 30px; + padding: 0 12px; + color: #5B67F1; + background-color: #EEF1FF; + border: 0; + border-radius: 8px; + font-weight: 600; +} +QDialog#AiConsultDialog QPushButton#AiConsultBack:hover { background-color: #E4E8FF; } +QDialog#AiConsultDialog QFrame#AiConsultHero { + background-color: #FFFFFF; + border: 1px solid #E6EAF5; + border-radius: 16px; +} +QDialog#AiConsultDialog QLabel#AiConsultAvatar { + color: #FFFFFF; + background-color: #5B67F1; + border-radius: 22px; + font-size: 16px; + font-weight: 700; +} +QDialog#AiConsultDialog QLabel#AiConsultName { + color: #15224A; + background-color: transparent; + font-size: 18px; + font-weight: 700; +} +QDialog#AiConsultDialog QLabel#AiConsultMeta { + color: #7481A3; + background-color: transparent; + font-size: 12px; +} +QDialog#AiConsultDialog QLabel#AiConsultChip { + color: #5A6794; + background-color: #F3F5FB; + border-radius: 8px; + padding: 4px 8px; + font-size: 11px; +} +QDialog#AiConsultDialog QPushButton#AiConsultFullRecord { + min-height: 34px; + max-height: 34px; + padding: 0 14px; + color: #5B67F1; + background-color: #FFFFFF; + border: 1px solid #D7DCF0; + border-radius: 10px; + font-weight: 600; +} +QDialog#AiConsultDialog QPushButton#AiConsultFullRecord:hover { background-color: #F7F8FF; } +QDialog#AiConsultDialog QTabBar#AiConsultTabs { + background-color: transparent; +} +QDialog#AiConsultDialog QTabBar#AiConsultTabs::tab { + min-height: 36px; + padding: 0 16px; + margin-right: 4px; + color: #7481A3; + background-color: transparent; + border: 0; + border-bottom: 2px solid transparent; + font-size: 13px; +} +QDialog#AiConsultDialog QTabBar#AiConsultTabs::tab:selected { + color: #5B67F1; + font-weight: 700; + border-bottom: 2px solid #5B67F1; +} +QDialog#AiConsultDialog QFrame#AiConsultChatPane, +QDialog#AiConsultDialog QFrame#AiConsultSide, +QDialog#AiConsultDialog QFrame#AiConsultRecordPane { + background-color: #FFFFFF; + border: 1px solid #E6EAF5; + border-radius: 16px; +} +QDialog#AiConsultDialog QFrame#AiConsultBubblePatient, +QDialog#AiConsultDialog QFrame#AiConsultBubbleDoctor, +QDialog#AiConsultDialog QFrame#AiConsultBubbleAi { + border-radius: 14px; +} +QDialog#AiConsultDialog QFrame#AiConsultBubblePatient { background-color: #F3F5FB; } +QDialog#AiConsultDialog QFrame#AiConsultBubbleDoctor { background-color: #5B67F1; } +QDialog#AiConsultDialog QFrame#AiConsultBubbleAi { + background-color: #F3F6FF; + border: 1px solid #E0E6FF; +} +QDialog#AiConsultDialog QLabel#AiConsultRole { + min-width: 28px; + max-width: 28px; + min-height: 28px; + max-height: 28px; + border-radius: 14px; + color: #FFFFFF; + font-size: 11px; + font-weight: 700; +} +QDialog#AiConsultDialog QTextBrowser#AiConsultBubbleText { + background-color: transparent; + border: 0; + color: #3F4E75; + font-size: 13px; + padding: 0; +} +QDialog#AiConsultDialog QFrame#AiConsultBubbleDoctor QTextBrowser#AiConsultBubbleText { + color: #FFFFFF; +} +QDialog#AiConsultDialog QLabel#AiConsultTime { + color: #A0A8C2; + background-color: transparent; + font-size: 11px; +} +QDialog#AiConsultDialog QLabel#AiConsultAiTitle { + color: #5B67F1; + background-color: transparent; + font-size: 13px; + font-weight: 700; +} +QDialog#AiConsultDialog QLabel#AiConsultSection { + color: #15224A; + background-color: transparent; + font-size: 13px; + font-weight: 700; +} +QDialog#AiConsultDialog QLabel#AiConsultBody { + color: #3F4E75; + background-color: transparent; + font-size: 12px; +} +QDialog#AiConsultDialog QLabel#AiConsultTag[level="high"] { + color: #F15B67; + background-color: #FFF1F3; + border-radius: 8px; + padding: 2px 8px; + font-size: 11px; + font-weight: 700; +} +QDialog#AiConsultDialog QLabel#AiConsultTag[level="medium"] { + color: #D38625; + background-color: #FFF5E6; + border-radius: 8px; + padding: 2px 8px; + font-size: 11px; + font-weight: 700; +} +QDialog#AiConsultDialog QLabel#AiConsultTag[level="low"] { + color: #17A77D; + background-color: #EAF9F3; + border-radius: 8px; + padding: 2px 8px; + font-size: 11px; + font-weight: 700; +} +QDialog#AiConsultDialog QLabel#AiConsultTag[level="warn"] { + color: #F15B67; + background-color: #FFF1F3; + border-radius: 8px; + padding: 2px 8px; + font-size: 11px; + font-weight: 700; +} +QDialog#AiConsultDialog QPushButton#AiConsultChipButton { + min-height: 30px; + max-height: 32px; + padding: 0 12px; + color: #5A6794; + background-color: #F3F5FB; + border: 0; + border-radius: 15px; + font-size: 12px; + font-weight: 500; +} +QDialog#AiConsultDialog QPushButton#AiConsultChipButton:hover { + background-color: #E8ECFA; + color: #5B67F1; +} +QDialog#AiConsultDialog QLineEdit#AiConsultInput { + min-height: 44px; + padding: 10px 14px; + color: #15224A; + background-color: #F7F8FC; + border: 1px solid #E6EAF5; + border-radius: 14px; + font-size: 13px; +} +QDialog#AiConsultDialog QPushButton#AiConsultIconButton { + min-width: 36px; + max-width: 36px; + min-height: 36px; + max-height: 36px; + padding: 0; + border: 0; + border-radius: 18px; + background-color: #F3F5FB; +} +QDialog#AiConsultDialog QPushButton#AiConsultIconButton:hover { background-color: #E8ECFA; } +QDialog#AiConsultDialog QPushButton#AiConsultSend { + min-width: 40px; + max-width: 40px; + min-height: 40px; + max-height: 40px; + padding: 0; + border: 0; + border-radius: 20px; + background-color: #5B67F1; +} +QDialog#AiConsultDialog QPushButton#AiConsultSend:hover { background-color: #4C57E9; } +QDialog#AiConsultDialog QPushButton#AiConsultSend:disabled { background-color: #C9CEF3; } +QDialog#AiConsultDialog QLabel#AiConsultSideTitle { + color: #15224A; + background-color: transparent; + font-size: 16px; + font-weight: 700; +} +QDialog#AiConsultDialog QPushButton#AiConsultSideClose { + min-width: 28px; + max-width: 28px; + min-height: 28px; + max-height: 28px; + padding: 0; + border: 0; + border-radius: 8px; + color: #7481A3; + background-color: transparent; + font-size: 16px; +} +QDialog#AiConsultDialog QPushButton#AiConsultSideClose:hover { background-color: #F3F5FB; } +QDialog#AiConsultDialog QFrame#AiConsultKeyCard, +QDialog#AiConsultDialog QFrame#AiConsultToolCard, +QDialog#AiConsultDialog QFrame#AiConsultSuggestRow, +QDialog#AiConsultDialog QFrame#AiConsultInsightRow { + background-color: #F7F8FC; + border: 0; + border-radius: 12px; +} +QDialog#AiConsultDialog QFrame#AiConsultToolCard { + min-height: 72px; +} +QDialog#AiConsultDialog QFrame#AiConsultToolCard:hover, +QDialog#AiConsultDialog QFrame#AiConsultSuggestRow:hover { + background-color: #EEF1FF; +} +QDialog#AiConsultDialog QLabel#AiConsultKeyValue { + color: #15224A; + background-color: transparent; + font-size: 14px; + font-weight: 700; +} +QDialog#AiConsultDialog QLabel#AiConsultKeyLabel { + color: #8A94B3; + background-color: transparent; + font-size: 11px; +} +QDialog#AiConsultDialog QLabel#AiConsultEmpty { + color: #8A94B3; + background-color: transparent; + font-size: 13px; +} +QDialog#AiConsultDialog QLabel#AiConsultRecordTitle { + color: #15224A; + background-color: transparent; + font-size: 13px; + font-weight: 700; +} +QDialog#AiConsultDialog QLabel#AiConsultRecordBody { + color: #3F4E75; + background-color: transparent; + font-size: 13px; +} +QDialog#AiConsultDialog QLabel#AiConsultRecordState { + color: #7481A3; + background-color: #F7F8FC; + border-radius: 8px; + padding: 7px 10px; + font-size: 12px; +} +QDialog#AiConsultDialog QLabel#AiConsultRecordState[state="error"] { + color: #B33F4A; + background-color: #FFF1F3; +} +QDialog#AiConsultDialog QLabel#AiConsultRecordState[state="warning"] { + color: #9A650F; + background-color: #FFF8E8; +} +QDialog#AiConsultDialog QLabel#AiConsultRecordState[state="empty"] { + color: #8A94B3; + background-color: #F7F8FC; +} +QDialog#AiConsultDialog QPushButton#AiConsultRecordRetry, +QDialog#AiConsultDialog QPushButton#AiConsultPrescriptionOpen, +QDialog#AiConsultDialog QPushButton#AiConsultMediaOpen { + min-height: 30px; + padding: 0 11px; + color: #5B67F1; + background-color: #EEF1FF; + border: 0; + border-radius: 8px; + font-weight: 600; +} +QDialog#AiConsultDialog QPushButton#AiConsultRecordRetry:hover, +QDialog#AiConsultDialog QPushButton#AiConsultPrescriptionOpen:hover, +QDialog#AiConsultDialog QPushButton#AiConsultMediaOpen:hover { background-color: #E2E7FF; } +QDialog#AiConsultDialog QPushButton#AiConsultPrescriptionOpen:disabled, +QDialog#AiConsultDialog QPushButton#AiConsultMediaOpen:disabled { + color: #A0A8C2; + background-color: #F0F2F7; +} +QDialog#AiConsultDialog QPushButton#AiConsultTongueThumb { + color: #5B67F1; + background-color: #EEF1FF; + border: 1px solid #DDE3FA; + border-radius: 9px; + padding: 0; + font-size: 11px; + font-weight: 600; +} +QDialog#AiConsultDialog QPushButton#AiConsultTongueThumb[loadState="loading"] { + color: #8A94B3; + background-color: #F3F5FB; +} +QDialog#AiConsultDialog QPushButton#AiConsultTongueThumb[loadState="ready"] { + color: transparent; + background-color: #FFFFFF; +} +QDialog#AiConsultDialog QPushButton#AiConsultTongueThumb:hover { + border-color: #8D98FF; + background-color: #E8ECFF; +} +QDialog#AiConsultDialog QFrame#AiConsultPrescriptionCard, +QDialog#AiConsultDialog QFrame#AiConsultTimelineCard, +QDialog#AiConsultDialog QFrame#AiConsultDataGroup { + background-color: #F7F8FC; + border: 1px solid #E8ECF6; + border-radius: 11px; +} +QDialog#AiConsultDialog QLabel#AiConsultDataLabel { + color: #8A94B3; + font-size: 11px; +} +QDialog#AiConsultDialog QLabel#AiConsultDataValue { + color: #34436B; + font-size: 12px; +} +""" + +QUICK_PROMPTS: tuple[tuple[str, str], ...] = ( + ("总结当前病情", "请根据当前病历总结患者病情、证候与风险。"), + ("给出用药建议", "请评估当前用药是否合理,并给出调整建议。"), + ("建议检查项目", "请根据现病史给出下一步检查与检验建议。"), + ("并发症风险评估", "请评估并发症风险并给出随访要点。"), + ("生成问诊问题", "请生成接下来应向患者确认的关键问诊问题。"), +) +TOOL_ITEMS: tuple[tuple[str, str, str], ...] = ( + ("chart", "分析病历", "请根据当前病历给出辨证与病情摘要。"), + ("trend", "血糖趋势", "请评估该患者的血糖控制情况与趋势。"), + ("pill", "用药评估", "请评估当前用药是否合理并给出调整建议。"), + ("alert", "并发症风险", "请评估并发症风险并给出随访建议。"), + ("note", "生成问诊小结", "请生成本次问诊小结,便于写入病历。"), + ("heart", "健康建议", "请给出饮食、运动与生活方式建议。"), +) +SUGGESTIONS: tuple[str, ...] = ( + "近期空腹血糖波动大吗?有无低血糖?", + "目前口服药依从性如何?有无漏服?", + "饮食控制是否严格执行?主食量如何?", + "是否规律监测血压、体重与腰围?", + "有无手足麻木、视力模糊或皮肤破损?", +) +_HTML_MARKERS = (" bool: + return can_use_diagnosis_ai_assistant(permissions) + + +def _as_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _as_float(value: Any) -> float | None: + if value in (None, "", "—"): + return None + try: + return float(str(value).replace("cm", "").replace("kg", "").strip()) + except (TypeError, ValueError): + return None + + +def _truthy(value: Any) -> bool: + if isinstance(value, bool): + return value + return str(value or "").strip().lower() in {"1", "true", "yes", "on", "是", "有"} + + +def _as_mapping(value: Any) -> dict[str, Any]: + if isinstance(value, Mapping): + return dict(value) + raw = getattr(value, "raw", None) + return dict(raw) if isinstance(raw, Mapping) else {} + + +def _style_surface(widget: QWidget) -> QWidget: + widget.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + return widget + + +def _pick(keys: Sequence[str], *sources: Any) -> Any: + for source in sources: + if source in (None, "", {}, []): + continue + value = first_value(source, *keys) + if value not in (None, "", "—"): + return value + return None + + +def _paint_icon(kind: str, color: str = "#5B67F1", size: int = 18) -> QIcon: + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + pen = QPen(QColor(color), 1.6) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + mid = size / 2 + if kind == "mic": + painter.drawRoundedRect(mid - 4, 3, 8, 10, 4, 4) + painter.drawArc(int(mid - 7), 8, 14, 12, 0, -180 * 16) + painter.drawLine(int(mid), 20, int(mid), size - 3) + painter.drawLine(int(mid - 4), size - 3, int(mid + 4), size - 3) + elif kind == "send": + painter.setBrush(QColor("#FFFFFF")) + painter.setPen(Qt.PenStyle.NoPen) + path = QPainterPath() + path.moveTo(3, mid) + path.lineTo(size - 3, 4) + path.lineTo(size - 8, mid) + path.lineTo(size - 3, size - 4) + path.closeSubpath() + painter.drawPath(path) + elif kind == "chart": + painter.drawLine(4, size - 4, size - 4, size - 4) + painter.drawLine(4, 4, 4, size - 4) + painter.drawLine(6, size - 6, 9, 8) + painter.drawLine(9, 8, 13, 12) + painter.drawLine(13, 12, size - 4, 5) + elif kind == "trend": + painter.drawLine(3, size - 5, 7, 11) + painter.drawLine(7, 11, 11, 14) + painter.drawLine(11, 14, size - 3, 5) + elif kind == "pill": + painter.drawRoundedRect(4, 7, size - 8, 6, 3, 3) + painter.drawLine(mid, 7, mid, 13) + elif kind == "alert": + painter.drawPolygon( + QPolygonF( + [QPointF(mid, 3), QPointF(size - 3, size - 3), QPointF(3, size - 3)] + ) + ) + painter.drawLine(int(mid), 8, int(mid), 12) + painter.drawPoint(int(mid), 15) + elif kind == "note": + painter.drawRoundedRect(4, 3, size - 8, size - 6, 2, 2) + painter.drawLine(7, 8, size - 7, 8) + painter.drawLine(7, 12, size - 7, 12) + else: + painter.drawEllipse(4, 6, 10, 10) + painter.drawArc(7, 11, 10, 8, 20 * 16, 140 * 16) + painter.end() + return QIcon(pixmap) + + +def _format_time(value: Any) -> str: + if value in (None, ""): + return "" + if isinstance(value, (int, float)) and value > 1_000_000_000: + return datetime.fromtimestamp(int(value)).strftime("%H:%M") + text = str(value) + if " " in text: + return text.split(" ")[-1][:5] + return text[:16] + + +def _join_meta(*parts: Any) -> str: + return " · ".join( + str(part).strip() + for part in parts + if part not in (None, "", "—") + ) + + +def render_chat_payload(browser: QTextBrowser, raw: Any, *, role: str = "ai") -> None: + """Render markdown, HTML, JSON or plain text inside a chat bubble.""" + + browser.document().setDefaultStyleSheet( + _DOCTOR_DOCUMENT_CSS if role == "doctor" else _AI_DOCUMENT_CSS + ) + if raw in (None, ""): + browser.clear() + return + if isinstance(raw, (Mapping, list, tuple)): + browser.setPlainText(json.dumps(raw, ensure_ascii=False, indent=2)) + return + text = str(raw).strip() + if not text: + browser.clear() + return + if text[0] in "{[": + try: + parsed = json.loads(text) + except (TypeError, ValueError, json.JSONDecodeError): + parsed = None + if parsed is not None: + browser.setPlainText(json.dumps(parsed, ensure_ascii=False, indent=2)) + return + lowered = text[:240].lower() + if text.startswith("<") and any(marker in lowered for marker in _HTML_MARKERS): + browser.setHtml(text) + return + browser.setMarkdown(text) + + +def _unwrap_analysis(payload: Any) -> dict[str, Any]: + if not isinstance(payload, Mapping): + return {} + current: Mapping[str, Any] = payload + latest = current.get("latest_by_model") + if isinstance(latest, Mapping): + for key in ("qwen", "openai"): + row = latest.get(key) + if isinstance(row, Mapping): + current = row + break + elif isinstance(current.get("reports"), Sequence) and not isinstance( + current.get("reports"), (str, bytes, bytearray) + ): + rows = [row for row in current.get("reports") or [] if isinstance(row, Mapping)] + if rows: + current = rows[0] + nested = current.get("report") + merged = dict(current) + if isinstance(nested, Mapping): + for key, value in nested.items(): + if value not in (None, "", [], {}): + merged[key] = value + if "diagnosis_advice" not in merged or merged.get("diagnosis_advice") in (None, ""): + diagnosis = merged.get("diagnosis") + if isinstance(diagnosis, str) and diagnosis.strip(): + merged["diagnosis_advice"] = diagnosis + return merged + + +def _bmi_text(*sources: Any) -> str: + explicit = _as_float(_pick(("bmi",), *sources)) + if explicit is not None: + return f"{explicit:.1f}" + height = _as_float(_pick(("height",), *sources)) + weight = _as_float(_pick(("weight",), *sources)) + if height and height > 0 and weight is not None: + return f"{weight / ((height / 100) ** 2):.1f}" + return "" + + +def _course_text(*sources: Any) -> str: + value = _pick( + ( + "disease_course_text", + "illness_years", + "diabetes_years", + "diabetes_discovery_year_text", + "diabetes_discovery_year", + "course", + ), + *sources, + ) + if value in (None, "", "—"): + return "" + text = str(value).strip() + if text.startswith("病程"): + return text + if text.replace(".", "", 1).isdigit(): + return f"病程 {text} 年" if float(text) > 30 else f"{text}年" + if "年" in text: + return text + return text + + +def _smoking_text(*sources: Any) -> str: + value = _pick(("smoking", "smoke", "smoking_history", "smoke_history"), *sources) + if value not in (None, "", "—"): + return str(value) + history = str(_pick(("past_history",), *sources) or "") + if any(token in history for token in ("不吸烟", "否认吸烟", "无吸烟")): + return "不吸烟" + if any(token in history for token in ("吸烟", "抽烟", "烟龄")): + return "有吸烟史" + return "" + + +def _family_text(*sources: Any) -> str: + value = _pick(("family_history",), *sources) + if value in (None, "", "—"): + return "" + normalized = str(value).strip().lower() + return {"0": "无", "false": "无", "1": "有", "true": "有"}.get( + normalized, _human_value(value) + ) + + +_FIELD_LABELS: dict[str, str] = { + "id": "编号", + "diagnosis_id": "诊单编号", + "patient_id": "患者编号", + "patient_name": "患者姓名", + "name": "姓名", + "phone": "手机号", + "patient_phone": "手机号", + "phone_masked": "手机号", + "id_card": "身份证号", + "gender": "性别", + "gender_desc": "性别", + "age": "年龄", + "birthday": "出生日期", + "marital_status": "婚姻状况", + "region": "所在地区", + "address": "详细地址", + "height": "身高", + "weight": "体重", + "bmi": "BMI", + "systolic_pressure": "收缩压", + "diastolic_pressure": "舒张压", + "fasting_blood_sugar": "空腹血糖", + "postprandial_blood_sugar": "餐后血糖", + "smoking": "吸烟情况", + "smoking_history": "吸烟史", + "drinking": "饮酒情况", + "drinking_history": "饮酒史", + "exercise_condition": "运动情况", + "chief_complaint": "主诉", + "present_illness": "现病史", + "past_history": "既往史", + "allergy_history": "过敏史", + "family_history": "家族史", + "trauma_history": "外伤史", + "surgery_history": "手术史", + "pregnancy_history": "妊娠史", + "clinical_diagnosis": "临床诊断", + "local_hospital_diagnosis": "当地医院诊断", + "local_hospital_name": "当地医院", + "diagnosis_date": "诊断日期", + "diagnosis_type": "诊断类型", + "diabetes_discovery_year": "糖尿病病史", + "current_medications": "在用药物", + "diet_condition": "饮食情况", + "body_feeling": "肢体感受", + "sleep_condition": "睡眠情况", + "eye_condition": "眼部情况", + "head_feeling": "头部感受", + "sweat_condition": "出汗情况", + "skin_condition": "皮肤情况", + "urine_condition": "小便情况", + "stool_condition": "大便情况", + "kidney_condition": "腰肾情况", + "symptoms": "其他症状", + "tongue": "舌象", + "tongue_coating": "舌苔", + "tongue_condition": "舌象", + "pulse": "脉象", + "pulse_condition": "脉象", + "remark": "病史补充", + "prescription_opinion": "处方意见", + "prescription_name": "方名", + "prescription_type": "方型", + "formula_type": "方型", + "latest_prescription_order": "最新处方订单", + "status_text": "状态", + "content": "内容", + "create_time": "记录时间", + "record_date": "记录日期", + "source": "来源", + "breakfast_foods": "早餐", + "lunch_foods": "午餐", + "dinner_foods": "晚餐", + "note": "备注", + "exercise_type": "运动方式", + "duration": "时长", + "intensity": "强度", +} + + +def _field_label(key: Any) -> str: + text = str(key or "").strip() + return _FIELD_LABELS.get(text, text.replace("_", " ") or "字段") + + +def _human_value(value: Any, *, empty: str = "未记录") -> str: + """Render nested API values without leaking Python repr punctuation.""" + + if value in (None, "", "—"): + return empty + if isinstance(value, bool): + return "是" if value else "否" + if isinstance(value, Mapping): + parts = [] + for key, nested in value.items(): + rendered = _human_value(nested, empty="") + if rendered: + parts.append(f"{_field_label(key)}:{rendered}") + return ";".join(parts) or empty + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + parts = [_human_value(item, empty="") for item in value] + return "、".join(part for part in parts if part) or empty + return str(value).strip() or empty + + +def _human_mapping(source: Any) -> dict[str, Any]: + """Flatten complex values to readable strings before feeding readonly widgets.""" + + mapping = _as_mapping(source) + return { + str(key): _human_value(value) if isinstance(value, (Mapping, list, tuple)) else value + for key, value in mapping.items() + } + + +def _mask_phone(value: Any) -> str: + text = str(value or "").strip() + if not text or "*" in text: + return text + digits = "".join(character for character in text if character.isdigit()) + if len(digits) < 7: + return "***" + return f"{digits[:3]}****{digits[-4:]}" + + +def _mask_id_card(value: Any) -> str: + text = str(value or "").strip() + if not text or "*" in text: + return text + if len(text) <= 7: + return "***" + return f"{text[:3]}***********{text[-4:]}" + + +def _rows(value: Any) -> list[Any]: + return page_items(value) if value not in (None, "") else [] + + +def _attachment_items(value: Any) -> list[Any]: + if value in (None, ""): + return [] + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return list(value) + return [value] + + +def _exact_positive_id(value: Any, expected: int) -> bool: + """Match JSON ownership ids without accepting bools or string coercion.""" + + return ( + type(expected) is int + and expected > 0 + and type(value) is int + and value == expected + ) + + +def _owned_rows( + value: Any, + diagnosis_id: int, + *, + require_owner: bool = False, +) -> tuple[list[Any], int]: + accepted: list[Any] = [] + rejected = 0 + for row in _rows(value): + owner = first_value(row, "diagnosis_id", default=None) + if require_owner: + owned = _exact_positive_id(owner, diagnosis_id) + else: + owned = owner in (None, "") or _exact_positive_id(owner, diagnosis_id) + if not owned: + rejected += 1 + continue + accepted.append(row) + return accepted, rejected + + +def _report_response_matches_patient(payload: Any, patient_id: int) -> bool: + """Reject a report response if any declared patient owner is not exact.""" + + if isinstance(payload, Mapping): + if "patient_id" in payload and not _exact_positive_id( + payload.get("patient_id"), patient_id + ): + return False + return all( + _report_response_matches_patient(value, patient_id) + for value in payload.values() + ) + if isinstance(payload, Sequence) and not isinstance( + payload, (str, bytes, bytearray) + ): + return all( + _report_response_matches_patient(value, patient_id) for value in payload + ) + return True + + +def _https_origin(value: Any) -> tuple[str, int] | None: + """Return a normalized HTTPS host/port pair for trusted thumbnail checks.""" + + try: + parsed = urlsplit(str(value or "").strip()) + port = parsed.port or 443 + except ValueError: + return None + if ( + parsed.scheme.lower() != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + ): + return None + return parsed.hostname.rstrip(".").lower(), port + + +def _configured_media_origins(repository: Any) -> set[tuple[str, int]]: + """Read explicit local media configuration plus the API client's origin.""" + + origins: set[tuple[str, int]] = set() + client = getattr(repository, "client", None) + candidates: list[Any] = [ + getattr(client, "base_url", None), + getattr(repository, "media_base_url", None), + ] + for name in ( + "trusted_media_origins", + "allowed_media_origins", + "trusted_media_domains", + "allowed_media_domains", + ): + configured = getattr(repository, name, None) + if isinstance(configured, (Sequence, set, frozenset)) and not isinstance( + configured, (str, bytes, bytearray) + ): + candidates.extend(configured) + elif configured: + candidates.append(configured) + for candidate in candidates: + text = str(candidate or "").strip() + if not text: + continue + origin = _https_origin(text if "://" in text else f"https://{text}") + if origin is not None: + origins.add(origin) + return origins + + +def _trusted_thumbnail_url(repository: Any, target: str) -> bool: + origin = _https_origin(target) + return origin is not None and origin in _configured_media_origins(repository) + + +def _analysis_markdown(analysis: Mapping[str, Any]) -> str: + advice = display_text(first_value(analysis, "diagnosis_advice", "diagnosis"), "") + treatment = display_text(first_value(analysis, "treatment_advice"), "") + risks = first_value(analysis, "risk_assessment", default=[]) + risk_lines: list[str] = [] + if isinstance(risks, Sequence) and not isinstance(risks, (str, bytes, bytearray)): + for row in risks: + if isinstance(row, Mapping): + label = display_text(first_value(row, "label", "name"), "") + level = display_text(first_value(row, "level"), "") + if label: + risk_lines.append(f"- **{label}**" + (f"({level})" if level else "")) + elif str(row).strip(): + risk_lines.append(f"- {row}") + elif isinstance(risks, str) and risks.strip(): + risk_lines.append(risks.strip()) + if not advice and not treatment and not risk_lines: + return "" + parts = ["### 1. 病情与证候分析"] + if advice: + parts.append(f"**核心病机推测:** {advice}") + if risk_lines: + parts.extend(["", "### 2. 风险提示", *risk_lines]) + if treatment: + parts.extend(["", "### 3. 建议下一步", treatment]) + return "\n\n".join(parts) + + +class _FlowRow(QWidget): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._layout = QHBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(8) + self._layout.setAlignment( + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter + ) + + def add(self, widget: QWidget) -> None: + self._layout.addWidget(widget) + + +class _RichMessage(QTextBrowser): + """Chat body that auto-detects markdown / HTML / JSON / plain text.""" + + def __init__(self, role: str, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._role = role + self.setObjectName("AiConsultBubbleText") + self.setFrameShape(QFrame.Shape.NoFrame) + self.setOpenExternalLinks(True) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setReadOnly(True) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self._fitting = False + self.document().contentsChanged.connect(self._fit) + + def set_payload(self, raw: Any) -> None: + render_chat_payload(self, raw, role=self._role) + QTimer.singleShot(0, self._fit) + + def resizeEvent(self, event) -> None: # type: ignore[override] + super().resizeEvent(event) + self._fit() + + def _fit(self) -> None: + if self._fitting: + return + self._fitting = True + try: + width = max(1, self.viewport().width()) + self.document().setTextWidth(width) + height = max(24, int(self.document().size().height()) + 8) + self.setMinimumHeight(height) + self.setMaximumHeight(height) + finally: + self._fitting = False + + +class _ChatBubble(QWidget): + def __init__( + self, + *, + role: str, + text: str = "", + time_text: str = "", + extra: QWidget | None = None, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + row = QHBoxLayout(self) + row.setContentsMargins(8, 4, 8, 4) + row.setSpacing(8) + avatar = QLabel("医" if role == "doctor" else "患" if role == "patient" else "AI") + avatar.setObjectName("AiConsultRole") + avatar.setAlignment(Qt.AlignmentFlag.AlignCenter) + _style_surface(avatar) + if role == "doctor": + avatar.setStyleSheet("background-color:#5B67F1; color:#FFFFFF; border-radius:14px;") + elif role == "ai": + avatar.setStyleSheet("background-color:#7B86F8; color:#FFFFFF; border-radius:14px;") + else: + avatar.setStyleSheet("background-color:#9AA3C4; color:#FFFFFF; border-radius:14px;") + bubble = QFrame() + _style_surface(bubble) + if role == "doctor": + bubble.setObjectName("AiConsultBubbleDoctor") + elif role == "ai": + bubble.setObjectName("AiConsultBubbleAi") + else: + bubble.setObjectName("AiConsultBubblePatient") + bubble.setMaximumWidth(720) + inner = QVBoxLayout(bubble) + self._inner = inner + inner.setContentsMargins(12, 10, 12, 10) + inner.setSpacing(6) + if extra is not None: + inner.addWidget(extra) + self.body: _RichMessage | None = None + if text or role == "ai": + self.body = _RichMessage(role, bubble) + self.body.set_payload(text) + inner.addWidget(self.body) + self.stamp: QLabel | None = None + if time_text: + self.stamp = QLabel(time_text) + self.stamp.setObjectName("AiConsultTime") + inner.addWidget(self.stamp) + if role == "doctor": + row.addStretch(1) + row.addWidget(bubble, 0) + row.addWidget(avatar, 0, Qt.AlignmentFlag.AlignBottom) + else: + row.addWidget(avatar, 0, Qt.AlignmentFlag.AlignBottom) + row.addWidget(bubble, 1 if role == "ai" else 0) + row.addStretch(1) + + def set_payload(self, text: str) -> None: + if self.body is not None: + self.body.set_payload(text) + + def set_time_text(self, text: str) -> None: + if not text: + return + if self.stamp is None: + self.stamp = QLabel(text) + self.stamp.setObjectName("AiConsultTime") + self._inner.addWidget(self.stamp) + else: + self.stamp.setText(text) + + +class _AiStreamSignals(QObject): + event = Signal(object) + error = Signal(object) + finished = Signal() + + +class _AiStreamWorker(QRunnable): + """Dialog-local streaming worker; it does not alter global WorkerSignals.""" + + def __init__( + self, + repository: Any, + *, + diagnosis_id: int, + prompt: str, + task: str, + ) -> None: + super().__init__() + self.repository = repository + self.diagnosis_id = diagnosis_id + self.prompt = prompt + self.task = task + self.signals = _AiStreamSignals() + self._cancelled = Event() + + def cancel(self) -> None: + self._cancelled.set() + + def is_cancelled(self) -> bool: + return self._cancelled.is_set() + + @Slot() + def run(self) -> None: + try: + stream = getattr(self.repository, "stream_diagnosis_ai", None) + if callable(stream): + events = stream( + self.diagnosis_id, + self.prompt, + task=self.task, + cancelled=self.is_cancelled, + ) + else: + result = invoke( + self.repository, + "analyze_diagnosis_ai", + diagnosis_id=self.diagnosis_id, + prompt=self.prompt, + task=self.task, + ) + payload = result if isinstance(result, Mapping) else {} + answer = str(first_value(payload, "answer", "content", default="") or "") + events = ( + {"event": "start", "fallback": True}, + {"event": "delta", "text": answer, "fallback": True}, + {**payload, "event": "done", "fallback": True}, + ) + for event in events: + if self.is_cancelled(): + return + self.signals.event.emit(event) + except Exception as error: # UI boundary: render transport/domain errors in place. + if not self.is_cancelled(): + self.signals.error.emit(error) + finally: + self.signals.finished.emit() + + +class _ClickCard(QFrame): + clicked = Signal() + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + _style_surface(self) + self.setCursor(Qt.CursorShape.PointingHandCursor) + + def mousePressEvent(self, event) -> None: # type: ignore[override] + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit() + super().mousePressEvent(event) + + +class AiConsultDialog(QDialog): + """Patient-scoped chat workspace with a persistent AI assistant rail.""" + + def __init__( + self, + repository: Any, + permissions: Any = None, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.repository = repository + self.permissions = permissions + self.diagnosis_id = 0 + self.patient_id = 0 + self._seed: Mapping[str, Any] = {} + self._detail: Mapping[str, Any] = {} + self._workspace_sections: dict[str, dict[str, Any]] = {} + self._generation = 0 + self._image_generation = 0 + self._prescription_detail_generation = 0 + self._prescription_detail_target = 0 + self._stream_generation = 0 + self._asking = False + self._stream_worker: _AiStreamWorker | None = None + self._stream_bubble: _ChatBubble | None = None + self._stream_text = "" + self._pending_chunks: list[str] = [] + self._stream_meta: dict[str, Any] = {} + self._follow_chat = True + self._flush_timer = QTimer(self) + self._flush_timer.setSingleShot(True) + self._flush_timer.setInterval(40) + self._flush_timer.timeout.connect(self._flush_stream_chunks) + self.setObjectName("AiConsultDialog") + self.setWindowTitle("问诊详情") + self.resize(1280, 820) + self.setMinimumSize(1080, 680) + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self.setStyleSheet(AI_CONSULT_QSS) + mark_business_dialog(self, "AiConsultDialog") + self._build() + + def open_for( + self, + *, + diagnosis_id: int, + patient_id: int = 0, + seed: Any = None, + source_title: str = "问诊列表", + ) -> None: + self._cancel_stream() + self.diagnosis_id = diagnosis_id + self.patient_id = _as_int(patient_id) + self._seed = dict(seed) if isinstance(seed, Mapping) else {} + self.source_crumb.setText(source_title) + self._generation += 1 + self._image_generation += 1 + self._clear_chat() + # Appointment seeds are presentation hints only. In the admin list, + # ``patient_id`` may actually be the diagnosis id, so it must never + # replace the separately supplied patient owner. + self._apply_header(self._seed, update_patient_id=False) + self._set_insights( + [ + ("血糖控制评估", "正在读取病历与已保存的 AI 报告…", "加载中", "low"), + ("并发症风险评估", "正在汇总风险提示…", "加载中", "medium"), + ("用药合理性评估", "正在读取用药与治疗建议…", "加载中", "low"), + ] + ) + self._set_records_loading() + self._load_workspace(self._generation) + + def _build(self) -> None: + shell = QFrame(self) + shell.setObjectName("AiConsultShell") + _style_surface(shell) + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.addWidget(shell) + layout = QVBoxLayout(shell) + layout.setContentsMargins(18, 14, 18, 14) + layout.setSpacing(12) + layout.addLayout(self._build_crumb()) + layout.addWidget(self._build_hero()) + self.tabs = QTabBar() + self.tabs.setObjectName("AiConsultTabs") + for title in ("问诊对话", "病历资料", "检查检验", "处方记录", "健康档案"): + self.tabs.addTab(title) + self.tabs.currentChanged.connect(self._tab_changed) + layout.addWidget(self.tabs) + + 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([860, 360]) + layout.addWidget(splitter, 1) + + def _build_crumb(self) -> QHBoxLayout: + row = QHBoxLayout() + row.setContentsMargins(2, 0, 2, 0) + row.setSpacing(6) + self.source_crumb = QLabel("问诊列表") + self.source_crumb.setObjectName("AiConsultCrumb") + row.addWidget(self.source_crumb) + sep = QLabel(">") + sep.setObjectName("AiConsultCrumb") + row.addWidget(sep) + current = QLabel("问诊详情") + current.setObjectName("AiConsultCrumbCurrent") + row.addWidget(current) + row.addStretch(1) + back = QPushButton("返回") + back.setObjectName("AiConsultBack") + back.clicked.connect(self.reject) + row.addWidget(back) + return row + + def _build_hero(self) -> QWidget: + card = QFrame() + card.setObjectName("AiConsultHero") + _style_surface(card) + row = QHBoxLayout(card) + row.setContentsMargins(16, 14, 16, 14) + row.setSpacing(14) + self.avatar = QLabel("患") + self.avatar.setObjectName("AiConsultAvatar") + self.avatar.setFixedSize(44, 44) + self.avatar.setAlignment(Qt.AlignmentFlag.AlignCenter) + _style_surface(self.avatar) + row.addWidget(self.avatar, 0, Qt.AlignmentFlag.AlignTop) + info = QVBoxLayout() + info.setSpacing(6) + self.name_label = QLabel("患者") + self.name_label.setObjectName("AiConsultName") + info.addWidget(self.name_label) + 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) + return card + + def _build_center(self) -> QWidget: + host = QWidget() + layout = QVBoxLayout(host) + layout.setContentsMargins(0, 0, 0, 0) + self.center_stack = QStackedWidget() + self.center_stack.addWidget(self._build_chat_pane()) + self.records = { + "病历资料": self._build_record_pane("病历资料"), + "检查检验": self._build_record_pane("检查检验"), + "处方记录": self._build_record_pane("处方记录"), + "健康档案": self._build_record_pane("健康档案"), + } + for pane in self.records.values(): + self.center_stack.addWidget(pane) + layout.addWidget(self.center_stack, 1) + return host + + def _build_chat_pane(self) -> QWidget: + pane = QFrame() + pane.setObjectName("AiConsultChatPane") + _style_surface(pane) + layout = QVBoxLayout(pane) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + self.chat_scroll = QScrollArea() + self.chat_scroll.setWidgetResizable(True) + self.chat_scroll.setFrameShape(QFrame.Shape.NoFrame) + self.chat_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.chat_host = QWidget() + self.chat_layout = QVBoxLayout(self.chat_host) + self.chat_layout.setContentsMargins(16, 16, 16, 8) + self.chat_layout.setSpacing(10) + self.chat_layout.addStretch(1) + self.chat_scroll.setWidget(self.chat_host) + scroll_bar = self.chat_scroll.verticalScrollBar() + scroll_bar.valueChanged.connect(self._chat_scroll_value_changed) + scroll_bar.rangeChanged.connect(self._chat_scroll_range_changed) + layout.addWidget(self.chat_scroll, 1) + + footer = QWidget() + foot = QVBoxLayout(footer) + foot.setContentsMargins(16, 4, 16, 14) + foot.setSpacing(8) + chips = QHBoxLayout() + chips.setSpacing(8) + hint = QLabel("快捷提问") + hint.setObjectName("AiConsultMeta") + chips.addWidget(hint) + for label, prompt in QUICK_PROMPTS: + button = QPushButton(label) + button.setObjectName("AiConsultChipButton") + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.clicked.connect( + lambda _checked=False, text=prompt: self._ask(text) + ) + chips.addWidget(button) + chips.addStretch(1) + foot.addLayout(chips) + composer = QHBoxLayout() + composer.setSpacing(8) + mic = QPushButton() + mic.setObjectName("AiConsultIconButton") + mic.setIcon(_paint_icon("mic", "#7481A3")) + mic.setIconSize(QSize(16, 16)) + mic.setCursor(Qt.CursorShape.PointingHandCursor) + mic.setToolTip("语音输入") + mic.clicked.connect( + lambda: show_toast(self, "当前工作站暂未接入语音输入。", "info") + ) + composer.addWidget(mic) + self.input = QLineEdit() + self.input.setObjectName("AiConsultInput") + self.input.setPlaceholderText("输入问题,或选择上方快捷提问…") + self.input.returnPressed.connect(self._submit) + composer.addWidget(self.input, 1) + send = QPushButton() + send.setObjectName("AiConsultSend") + send.setIcon(_paint_icon("send", "#FFFFFF")) + send.setIconSize(QSize(16, 16)) + send.setCursor(Qt.CursorShape.PointingHandCursor) + send.clicked.connect(self._submit) + self.send_button = send + composer.addWidget(send) + foot.addLayout(composer) + layout.addWidget(footer) + return pane + + def _build_record_pane(self, title: str) -> QWidget: + pane = QFrame() + pane.setObjectName("AiConsultRecordPane") + _style_surface(pane) + layout = QVBoxLayout(pane) + layout.setContentsMargins(20, 18, 20, 18) + layout.setSpacing(10) + top = QHBoxLayout() + heading = QLabel(title) + heading.setObjectName("AiConsultSection") + top.addWidget(heading) + top.addStretch(1) + retry = QPushButton("重新加载") + retry.setObjectName("AiConsultRecordRetry") + retry.setVisible(False) + retry.clicked.connect(self._retry_workspace) + top.addWidget(retry) + layout.addLayout(top) + state = QLabel("选择诊单后加载。") + state.setObjectName("AiConsultRecordState") + state.setWordWrap(True) + state.setProperty("state", "empty") + layout.addWidget(state) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + content = QWidget() + content_layout = QVBoxLayout(content) + content_layout.setContentsMargins(0, 2, 4, 6) + content_layout.setSpacing(10) + content_layout.addStretch(1) + scroll.setWidget(content) + layout.addWidget(scroll, 1) + body = QLabel("选择患者后加载。") + body.setObjectName("AiConsultRecordBody") + body.setWordWrap(True) + body.setVisible(False) + pane.body_label = body # type: ignore[attr-defined] + pane.state_label = state # type: ignore[attr-defined] + pane.retry_button = retry # type: ignore[attr-defined] + pane.record_scroll = scroll # type: ignore[attr-defined] + pane.content_layout = content_layout # type: ignore[attr-defined] + return pane + + def _retry_workspace(self) -> None: + if self.diagnosis_id <= 0: + return + self._generation += 1 + self._set_records_loading() + self._load_workspace(self._generation) + + def _set_records_loading(self) -> None: + for pane in self.records.values(): + self._set_record_state(pane, "正在加载诊单资料…", "loading") + + def _set_record_state( + self, + pane: QWidget, + text: str, + state: str = "ready", + *, + retry: bool = False, + ) -> None: + label = pane.state_label # type: ignore[attr-defined] + label.setText(text) + label.setProperty("state", state) + label.style().unpolish(label) + label.style().polish(label) + label.setVisible(bool(text)) + pane.retry_button.setVisible(retry) # type: ignore[attr-defined] + + @staticmethod + def _clear_record_content(pane: QWidget) -> None: + layout = pane.content_layout # type: ignore[attr-defined] + while layout.count() > 1: + item = layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + + @staticmethod + def _insert_record_widget(pane: QWidget, widget: QWidget) -> None: + pane.content_layout.insertWidget( # type: ignore[attr-defined] + pane.content_layout.count() - 1, widget # type: ignore[attr-defined] + ) + + def _build_side(self) -> QWidget: + side = QFrame() + side.setObjectName("AiConsultSide") + _style_surface(side) + side.setMinimumWidth(320) + side.setMaximumWidth(380) + outer = QVBoxLayout(side) + outer.setContentsMargins(16, 14, 16, 14) + outer.setSpacing(12) + header = QHBoxLayout() + title = QLabel("AI 助手") + title.setObjectName("AiConsultSideTitle") + header.addWidget(title) + header.addStretch(1) + close = QPushButton("×") + close.setObjectName("AiConsultSideClose") + close.clicked.connect(self.reject) + header.addWidget(close) + outer.addLayout(header) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + body = QWidget() + layout = QVBoxLayout(body) + layout.setContentsMargins(0, 0, 4, 0) + layout.setSpacing(12) + + self.side_name = QLabel("患者") + self.side_name.setObjectName("AiConsultName") + self.side_name.setStyleSheet("font-size:14px;") + layout.addWidget(self.side_name) + self.side_tags = QLabel("") + self.side_tags.setObjectName("AiConsultMeta") + self.side_tags.setWordWrap(True) + layout.addWidget(self.side_tags) + + keys_title = QLabel("患者关键信息") + keys_title.setObjectName("AiConsultSection") + layout.addWidget(keys_title) + self.key_grid = QGridLayout() + self.key_grid.setHorizontalSpacing(8) + self.key_grid.setVerticalSpacing(8) + layout.addLayout(self.key_grid) + + insight_title = QLabel("智能分析") + insight_title.setObjectName("AiConsultSection") + layout.addWidget(insight_title) + self.insight_host = QVBoxLayout() + self.insight_host.setSpacing(8) + layout.addLayout(self.insight_host) + + tools_title = QLabel("快捷工具") + tools_title.setObjectName("AiConsultSection") + layout.addWidget(tools_title) + tools = QGridLayout() + tools.setHorizontalSpacing(8) + tools.setVerticalSpacing(8) + for index, (kind, label, prompt) in enumerate(TOOL_ITEMS): + card = _ClickCard() + card.setObjectName("AiConsultToolCard") + card.clicked.connect(lambda text=prompt: self._ask(text)) + inner = QVBoxLayout(card) + inner.setContentsMargins(8, 10, 8, 10) + inner.setSpacing(6) + icon = QLabel() + icon.setPixmap(_paint_icon(kind).pixmap(18, 18)) + icon.setAlignment(Qt.AlignmentFlag.AlignCenter) + inner.addWidget(icon) + text = QLabel(label) + text.setObjectName("AiConsultMeta") + text.setAlignment(Qt.AlignmentFlag.AlignCenter) + inner.addWidget(text) + tools.addWidget(card, index // 3, index % 3) + layout.addLayout(tools) + + suggest_title = QLabel("对话建议") + suggest_title.setObjectName("AiConsultSection") + layout.addWidget(suggest_title) + for question in SUGGESTIONS: + row = _ClickCard() + row.setObjectName("AiConsultSuggestRow") + row.clicked.connect(lambda text=question: self._use_suggestion(text)) + inner = QHBoxLayout(row) + inner.setContentsMargins(10, 8, 10, 8) + label = QLabel(question) + label.setObjectName("AiConsultBody") + label.setWordWrap(True) + inner.addWidget(label, 1) + chevron = QLabel("›") + chevron.setObjectName("AiConsultMeta") + inner.addWidget(chevron) + layout.addWidget(row) + layout.addStretch(1) + scroll.setWidget(body) + outer.addWidget(scroll, 1) + return side + + def _tab_changed(self, index: int) -> None: + self.center_stack.setCurrentIndex(index) + + def _clear_chat(self) -> None: + while self.chat_layout.count() > 1: + item = self.chat_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + + def _append_bubble( + self, + role: str, + text: str, + *, + time_text: str = "", + extra: QWidget | None = None, + ) -> _ChatBubble: + bubble = _ChatBubble(role=role, text=text, time_text=time_text, extra=extra) + self.chat_layout.insertWidget(self.chat_layout.count() - 1, bubble) + if self._follow_chat: + QTimer.singleShot(0, self._scroll_chat_to_bottom) + return bubble + + def _chat_scroll_value_changed(self, value: int) -> None: + bar = self.chat_scroll.verticalScrollBar() + self._follow_chat = value >= bar.maximum() - 4 + + def _chat_scroll_range_changed(self, _minimum: int, _maximum: int) -> None: + if self._follow_chat: + QTimer.singleShot(0, self._scroll_chat_to_bottom) + + def _scroll_chat_to_bottom(self) -> None: + if not self._follow_chat: + return + bar = self.chat_scroll.verticalScrollBar() + bar.setValue(bar.maximum()) + + def _set_chips(self, values: Sequence[str]) -> None: + while self.chip_row._layout.count(): + item = self.chip_row._layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + for value in values: + if not value or value == "—": + continue + chip = QLabel(value) + chip.setObjectName("AiConsultChip") + _style_surface(chip) + self.chip_row.add(chip) + + def _set_key_facts(self, items: Sequence[tuple[str, str]]) -> None: + while self.key_grid.count(): + item = self.key_grid.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + for index, (label, value) in enumerate(items): + card = QFrame() + card.setObjectName("AiConsultKeyCard") + _style_surface(card) + inner = QVBoxLayout(card) + inner.setContentsMargins(10, 8, 10, 8) + inner.setSpacing(2) + number = QLabel(value or "未记录") + number.setObjectName("AiConsultKeyValue") + caption = QLabel(label) + caption.setObjectName("AiConsultKeyLabel") + inner.addWidget(number) + inner.addWidget(caption) + self.key_grid.addWidget(card, index // 2, index % 2) + + def _set_insights(self, rows: Sequence[tuple[str, str, str, str]]) -> None: + while self.insight_host.count(): + item = self.insight_host.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + for title, detail, tag, level in rows: + card = QFrame() + card.setObjectName("AiConsultInsightRow") + _style_surface(card) + inner = QHBoxLayout(card) + inner.setContentsMargins(10, 8, 10, 8) + text = QVBoxLayout() + text.setSpacing(2) + heading = QLabel(title) + heading.setObjectName("AiConsultRecordTitle") + body = QLabel(detail) + body.setObjectName("AiConsultBody") + body.setWordWrap(True) + text.addWidget(heading) + text.addWidget(body) + inner.addLayout(text, 1) + badge = QLabel(tag) + badge.setObjectName("AiConsultTag") + _style_surface(badge) + badge.setProperty("level", level) + badge.style().unpolish(badge) + badge.style().polish(badge) + inner.addWidget(badge, 0, Qt.AlignmentFlag.AlignTop) + self.insight_host.addWidget(card) + + def _apply_header( + self, + payload: Mapping[str, Any], + *, + update_patient_id: bool = False, + ) -> None: + diagnosis = _as_mapping(get_value(payload, "diagnosis", default=None) or payload) + patient = _as_mapping(get_value(payload, "patient", default={})) + appointment = _as_mapping(get_value(payload, "appointment", default={})) + sources = (diagnosis, patient, appointment, payload) + name = display_text( + _pick(("patient_name", "name"), *sources), + "患者", + ) + gender = gender_text(_pick(("gender", "sex", "gender_desc"), *sources)) + age = _pick(("age",), *sources) + age_text = f"{age}岁" if age not in (None, "", "—") else "" + self.name_label.setText(name) + self.avatar.setText((name or "患")[:1]) + self.identity_label.setText(_join_meta(gender, age_text) or "性别 / 年龄待完善") + resolved_patient = first_value( + diagnosis, + "patient_id", + "source_patient_id", + default=first_value( + patient, + "id", + "patient_id", + default=first_value( + appointment, + "source_patient_id", + default=first_value(payload, "source_patient_id", default=None), + ), + ), + ) + if update_patient_id and type(resolved_patient) is int and resolved_patient > 0: + self.patient_id = resolved_patient + phone = display_text( + _pick(("patient_phone", "phone", "mobile", "phone_masked"), *sources), + "", + ) + if phone and not has_permission( + self.permissions, "tcm.diagnosis/phonePlain", default=False + ): + phone = _mask_phone(phone) + source = display_text( + _pick(("source_desc", "source_name", "channel_name"), appointment, diagnosis), + "线上问诊", + ) + when = _join_meta( + _pick(("appointment_date", "date"), appointment, diagnosis), + _pick(("appointment_time", "time"), appointment, diagnosis), + ) + illness = display_text( + _pick(("clinical_diagnosis", "diagnosis_name"), diagnosis, appointment, payload), + "", + ) + chips = [ + f"患者ID {self.patient_id}" if self.patient_id > 0 else "", + phone, + source, + f"预约 {when}" if when else "", + illness, + ] + self._set_chips(chips) + self.side_name.setText(_join_meta(name, gender, age_text) or name) + self.side_tags.setText(_join_meta(illness, "需持续随访") or "暂无诊断标签") + self._set_key_facts( + [ + ("病程", _course_text(*sources) or "未记录"), + ("BMI", _bmi_text(*sources) or "未记录"), + ("吸烟", _smoking_text(*sources) or "未记录"), + ("家族史", _family_text(*sources) or "未记录"), + ] + ) + + def _load_workspace(self, generation: int) -> None: + diagnosis_id = self.diagnosis_id + patient_id = self.patient_id + if diagnosis_id <= 0: + show_toast(self, "缺少诊单编号,无法打开 AI 对话。", "warning") + return + + def section(name: str, **kwargs: Any) -> dict[str, Any]: + method = getattr(self.repository, name, None) + if not callable(method): + return { + "value": None, + "error": f"当前数据源不支持 {name}。", + } + try: + return {"value": invoke(self.repository, name, **kwargs), "error": ""} + except Exception as error: # Each workspace source has its own visible state. + return {"value": None, "error": friendly_error(error)} + + def load() -> dict[str, Any]: + detail_section = section( + "get_diagnosis_detail", + diagnosis_id=diagnosis_id, + readonly=True, + ) + detail = detail_section.get("value") + detail = detail if isinstance(detail, Mapping) else {} + diagnosis = _as_mapping( + get_value(detail, "diagnosis", default=detail) or detail + ) + actual_diagnosis_id = first_value( + diagnosis, "id", "diagnosis_id", default=None + ) + detail_is_current = bool(detail) and _exact_positive_id( + actual_diagnosis_id, diagnosis_id + ) + resolved_patient = 0 + report_error = "" + if detail_is_current: + detail_patient = _as_mapping(get_value(detail, "patient", default={})) + raw_patient_id = first_value( + diagnosis, + "patient_id", + "source_patient_id", + default=first_value( + detail_patient, + "id", + "patient_id", + default=None, + ), + ) + if type(raw_patient_id) is not int or raw_patient_id <= 0: + report_error = "当前诊单未返回可信患者编号,已停止加载患者 AI 报告。" + elif patient_id > 0 and raw_patient_id != patient_id: + report_error = "患者归属校验失败,已停止加载患者 AI 报告。" + else: + resolved_patient = raw_patient_id + elif detail_section.get("error"): + report_error = "诊单详情加载失败,未请求患者 AI 报告。" + else: + report_error = "诊单归属校验失败,未请求患者 AI 报告。" + messages = section( + "list_im_chat_messages", + diagnosis_id=diagnosis_id, + only_archived=True, + ) + if resolved_patient > 0: + reports = section( + "list_patient_ai_reports", patient_id=resolved_patient + ) + if reports.get("value") is not None and not _report_response_matches_patient( + reports.get("value"), resolved_patient + ): + reports = { + "value": None, + "error": "AI 报告患者归属校验失败,已拒绝显示。", + } + else: + reports = {"value": None, "error": report_error} + prescriptions = section( + "list_prescriptions_by_diagnosis", + diagnosis_id=diagnosis_id, + ) + notes = section("get_doctor_notes", diagnosis_id=diagnosis_id) + tracking = section("get_tracking_window", diagnosis_id=diagnosis_id) + return { + "diagnosis_id": diagnosis_id, + "patient_id": resolved_patient, + "detail": detail_section, + "messages": messages, + "analysis": { + "value": _unwrap_analysis(reports.get("value")), + "error": reports.get("error", ""), + }, + "prescriptions": prescriptions, + "notes": notes, + "tracking": tracking, + } + + run_async( + load, + on_success=lambda payload: self._workspace_loaded(generation, payload), + on_error=lambda error: self._workspace_failed(generation, diagnosis_id, error), + ) + + def _workspace_failed( + self, + generation: int, + diagnosis_id: int, + error: Exception, + ) -> None: + if generation != self._generation or diagnosis_id != self.diagnosis_id: + return + show_toast(self, friendly_error(error), "danger", 4200) + self._render_analysis({}) + for pane in self.records.values(): + self._set_record_state( + pane, + f"诊单资料加载失败:{friendly_error(error)}", + "error", + retry=True, + ) + if self.chat_layout.count() <= 1: + self._append_bubble( + "ai", + "病历或会话暂未完整加载。可先根据已有信息提问,稍后刷新再试。", + ) + + def _workspace_loaded(self, generation: int, payload: Mapping[str, Any]) -> None: + if ( + generation != self._generation + or not _exact_positive_id( + payload.get("diagnosis_id"), self.diagnosis_id + ) + ): + return + sections = { + key: dict(value) if isinstance(value, Mapping) else {"value": None, "error": "响应格式错误。"} + for key, value in payload.items() + if key not in {"diagnosis_id", "patient_id"} + } + detail_section = sections.get("detail", {"value": None, "error": "未返回病历资料。"}) + sections["detail"] = detail_section + detail = detail_section.get("value") + detail = detail if isinstance(detail, Mapping) else {} + diagnosis = _as_mapping(get_value(detail, "diagnosis", default=detail) or {}) + actual_diagnosis_id = first_value( + diagnosis, "id", "diagnosis_id", default=None + ) + if detail and not _exact_positive_id(actual_diagnosis_id, self.diagnosis_id): + detail_section["value"] = None + detail_section["warning"] = ( + "病历归属校验失败,已过滤不属于当前诊单的资料。" + ) + detail = {} + for key in ("notes", "prescriptions", "tracking"): + current = sections.setdefault(key, {}) + current["value"] = None + current["warning"] = ( + "病历归属校验失败,已过滤关联资料。" + ) + message_section = sections.get( + "messages", {"value": None, "error": "未返回资料。"} + ) + message_rows, rejected_messages = _owned_rows( + message_section.get("value"), self.diagnosis_id + ) + message_section["value"] = message_rows + if rejected_messages: + message_section["warning"] = ( + f"已过滤 {rejected_messages} 条其他诊单的会话。" + ) + sections["messages"] = message_section + for key in ("notes", "prescriptions"): + current = sections.get(key, {"value": None, "error": "未返回资料。"}) + rows, rejected = _owned_rows( + current.get("value"), + self.diagnosis_id, + require_owner=True, + ) + current["value"] = rows + if rejected: + current["warning"] = ( + f"已过滤 {rejected} 条缺少当前诊单归属的数据。" + ) + sections[key] = current + tracking_section = sections.get("tracking", {"value": None, "error": "未返回健康记录。"}) + tracking = tracking_section.get("value") + if isinstance(tracking, Mapping): + tracking_owner = first_value(tracking, "diagnosis_id", default=None) + if not _exact_positive_id(tracking_owner, self.diagnosis_id): + tracking_section["value"] = None + tracking_section["warning"] = ( + "健康记录缺少当前诊单归属,已过滤。" + ) + elif not tracking_section.get("error") and not tracking_section.get("warning"): + tracking_section["value"] = None + tracking_section["warning"] = ( + "健康记录响应未声明当前诊单归属,已过滤。" + ) + sections["tracking"] = tracking_section + self._workspace_sections = sections + self._detail = detail # type: ignore[assignment] + if detail: + self._apply_header(detail, update_patient_id=True) + message_section = sections.get("messages", {}) + self._render_messages(message_section.get("value")) + if message_section.get("error"): + self._append_bubble("ai", f"历史会话加载失败:{message_section['error']}") + analysis_section = sections.get("analysis", {}) + analysis = analysis_section.get("value") if isinstance(analysis_section.get("value"), Mapping) else {} + self._render_analysis(analysis) + if analysis_section.get("error"): + self._append_bubble( + "ai", + f"已保存的 AI 报告加载失败:{analysis_section['error']}。" + "当前智能分析仅依据本诊单已加载字段生成提示。", + ) + self._render_records(sections) + + def _render_messages(self, raw: Any) -> None: + rows = page_items(raw) if raw not in (None, "") else [] + if not rows: + self._append_bubble( + "ai", + "您好,我是 AI 问诊助手。可先查看右侧智能分析,或直接提问。", + ) + return + for row in rows: + if not isinstance(row, Mapping): + continue + from_doctor = bool(first_value(row, "is_from_doctor", "from_doctor", default=False)) + text = first_value(row, "text", "content", "file_name", default="") + kind = str(first_value(row, "msg_type", "type", default="text") or "text") + if kind != "text" and not text: + text = "图片" if kind == "image" else "附件" + if not text: + continue + self._append_bubble( + "doctor" if from_doctor else "patient", + str(text), + time_text=_format_time(first_value(row, "time", "create_time", "created_at")), + ) + + def _render_analysis(self, analysis: Mapping[str, Any]) -> None: + payload = _unwrap_analysis(analysis) + advice = display_text(first_value(payload, "diagnosis_advice", "diagnosis"), "") + treatment = display_text(first_value(payload, "treatment_advice"), "") + risks = first_value(payload, "risk_assessment", default=[]) + risk_rows = ( + [row for row in risks if isinstance(row, Mapping)] + if isinstance(risks, Sequence) and not isinstance(risks, (str, bytes, bytearray)) + else [] + ) + 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") + 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}], + } + ) + if markdown: + self._append_bubble("ai", markdown) + 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"), + ] + ) + + @staticmethod + def _section_error(section: Mapping[str, Any]) -> str: + return str(section.get("error") or "").strip() + + @staticmethod + def _section_warning(section: Mapping[str, Any]) -> str: + return str(section.get("warning") or "").strip() + + @staticmethod + def _error_is_permission(error: str) -> bool: + lowered = error.lower() + return any(token in lowered for token in ("权限", "无权", "forbidden", "permission", "403")) + + def _data_group( + self, + title: str, + pairs: Sequence[tuple[str, Any]], + *, + object_name: str = "AiConsultDataGroup", + columns: int = 2, + ) -> QWidget: + card = QFrame() + card.setObjectName(object_name) + _style_surface(card) + layout = QVBoxLayout(card) + layout.setContentsMargins(14, 12, 14, 13) + layout.setSpacing(9) + heading = QLabel(title) + heading.setObjectName("AiConsultRecordTitle") + layout.addWidget(heading) + grid = QGridLayout() + grid.setHorizontalSpacing(18) + grid.setVerticalSpacing(8) + for index, (label, value) in enumerate(pairs): + cell = QWidget() + cell_layout = QVBoxLayout(cell) + cell_layout.setContentsMargins(0, 0, 0, 0) + cell_layout.setSpacing(2) + key_label = QLabel(label) + key_label.setObjectName("AiConsultDataLabel") + value_label = QLabel(_human_value(value)) + value_label.setObjectName("AiConsultDataValue") + value_label.setTextFormat(Qt.TextFormat.PlainText) + value_label.setWordWrap(True) + value_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + cell_layout.addWidget(key_label) + cell_layout.addWidget(value_label) + grid.addWidget(cell, index // columns, index % columns) + layout.addLayout(grid) + return card + + def _inline_record_state( + self, + text: str, + state: str = "empty", + *, + retry: bool = False, + ) -> QWidget: + host = QWidget() + row = QHBoxLayout(host) + row.setContentsMargins(0, 0, 0, 0) + label = QLabel(text) + label.setObjectName("AiConsultRecordState") + label.setProperty("state", state) + label.setWordWrap(True) + row.addWidget(label, 1) + if retry: + button = QPushButton("重新加载") + button.setObjectName("AiConsultRecordRetry") + button.clicked.connect(self._retry_workspace) + row.addWidget(button) + return host + + def _render_records(self, sections: Mapping[str, Mapping[str, Any]]) -> None: + detail_section = sections.get("detail", {}) + detail = detail_section.get("value") + detail = detail if isinstance(detail, Mapping) else {} + self._render_case_record( + detail, + self._section_error(detail_section), + self._section_warning(detail_section), + ) + self._render_exam_record( + detail, + sections.get("notes", {}), + ) + self._render_prescription_record(sections.get("prescriptions", {})) + self._render_health_record( + detail, + self._section_error(detail_section), + self._section_warning(detail_section), + sections.get("tracking", {}), + ) + + def _render_case_record( + self, + detail: Mapping[str, Any], + error: str, + warning: str, + ) -> None: + pane = self.records["病历资料"] + self._clear_record_content(pane) + if not detail: + message = ( + "当前账号无权读取病历资料。" + if self._error_is_permission(error) + else f"病历资料加载失败:{error}" + if error + else warning + if warning + else "当前诊单暂无病历资料。" + ) + self._set_record_state( + pane, + message, + "error" if error else "warning" if warning else "empty", + retry=bool(error), + ) + pane.body_label.setText(message) # type: ignore[attr-defined] + return + diagnosis = _as_mapping(get_value(detail, "diagnosis", default=detail) or {}) + patient = _as_mapping(get_value(detail, "patient", default={})) + can_sensitive = has_permission( + self.permissions, "tcm.diagnosis/phonePlain", default=False + ) + safe_diagnosis = _human_mapping(diagnosis) + safe_patient = _human_mapping(patient) + if not safe_diagnosis.get("phone") and safe_diagnosis.get("patient_phone"): + safe_diagnosis["phone"] = safe_diagnosis["patient_phone"] + if not can_sensitive: + for source in (safe_diagnosis, safe_patient): + for key in ("phone", "patient_phone", "mobile"): + if source.get(key): + source[key] = _mask_phone(source[key]) + if source.get("id_card"): + source["id_card"] = _mask_id_card(source["id_card"]) + case_grid = CaseGrid() + case_grid.setObjectName("AiConsultCaseGrid") + case_grid.set_data(safe_diagnosis, safe_patient) + self._insert_record_widget(pane, case_grid) + diagnostic_keys = ( + "chief_complaint", + "present_illness", + "clinical_diagnosis", + "local_hospital_diagnosis", + "tongue", + "tongue_coating", + "pulse", + ) + self._insert_record_widget( + pane, + self._data_group( + "诊断与辨证", + [(_field_label(key), diagnosis.get(key)) for key in diagnostic_keys], + columns=1, + ), + ) + covered = { + field.key + for _group, _columns, fields in CaseGrid.GROUPS + for field in fields + } | set(diagnostic_keys) + extras = [ + (_field_label(key), value) + for key, value in safe_diagnosis.items() + if key not in covered + ] + if extras: + self._insert_record_widget( + pane, + self._data_group("其他诊单字段", extras, columns=2), + ) + summary = ";".join( + f"{_field_label(key)}:{_human_value(diagnosis.get(key))}" + for key in diagnostic_keys[:4] + ) + pane.body_label.setText(summary) # type: ignore[attr-defined] + if error: + self._set_record_state(pane, f"部分病历资料加载失败:{error}", "error", retry=True) + elif warning: + self._set_record_state(pane, warning, "warning") + else: + self._set_record_state(pane, "病历资料已按当前诊单完整加载。") + + def _render_exam_record( + self, + detail: Mapping[str, Any], + notes_section: Mapping[str, Any], + ) -> None: + pane = self.records["检查检验"] + self._clear_record_content(pane) + diagnosis = _as_mapping(get_value(detail, "diagnosis", default=detail) or {}) + pressure = _join_meta( + first_value(diagnosis, "systolic_pressure"), + first_value(diagnosis, "diastolic_pressure"), + ) + if pressure: + pressure = f"{pressure.replace(' · ', '/')} mmHg" + glucose = first_value(diagnosis, "fasting_blood_sugar") + if glucose not in (None, "", "—"): + glucose = f"{glucose} mmol/L" + self._insert_record_widget( + pane, + self._data_group( + "本诊单检查摘要", + [ + ("血压", pressure), + ("空腹血糖", glucose), + ("舌象", first_value(diagnosis, "tongue", "tongue_condition")), + ("舌苔", first_value(diagnosis, "tongue_coating", "tongue_fur")), + ], + ), + ) + api_notes = list(notes_section.get("value") or []) + aggregate_notes, rejected_aggregate_notes = _owned_rows( + get_value(detail, "doctor_notes", default=None) + or get_value(detail, "notes", default=None) + or first_value(diagnosis, "doctor_notes", "notes", default=[]), + self.diagnosis_id, + require_owner=True, + ) + merged: list[Any] = [] + seen_notes: set[tuple[str, str, str]] = set() + for note in [*api_notes, *aggregate_notes]: + key = ( + str(first_value(note, "id", default="") or ""), + str(first_value(note, "content", default="") or ""), + str(first_value(note, "create_time", "created_at", default="") or ""), + ) + if key in seen_notes: + continue + seen_notes.add(key) + merged.append(note) + aggregate_media = { + "diagnosis_id": self.diagnosis_id, + "content": "诊单聚合附件", + "tongue_images": first_value(diagnosis, "tongue_images", default=[]), + "report_files": first_value(diagnosis, "report_files", default=[]), + "create_time": first_value(diagnosis, "create_time", "diagnosis_date"), + } + if aggregate_media["tongue_images"] or aggregate_media["report_files"]: + merged.append(aggregate_media) + timeline = QWidget() + timeline.setObjectName("AiConsultExamTimeline") + timeline_layout = QVBoxLayout(timeline) + timeline_layout.setContentsMargins(0, 0, 0, 0) + timeline_layout.setSpacing(8) + media_seen: set[str] = set() + for note in sorted( + merged, + key=lambda row: str(first_value(row, "create_time", "created_at", default="")), + reverse=True, + ): + card = QFrame() + card.setObjectName("AiConsultTimelineCard") + _style_surface(card) + card_layout = QVBoxLayout(card) + card_layout.setContentsMargins(13, 11, 13, 12) + card_layout.setSpacing(7) + when = QLabel( + display_text( + first_value(note, "create_time", "created_at", "record_date"), + "时间未记录", + ) + ) + when.setObjectName("AiConsultMeta") + card_layout.addWidget(when) + content = QLabel(_human_value(first_value(note, "content", default=""), empty="无文字记录")) + content.setObjectName("AiConsultDataValue") + content.setTextFormat(Qt.TextFormat.PlainText) + content.setWordWrap(True) + card_layout.addWidget(content) + for field, label in (("tongue_images", "舌象附件"), ("report_files", "检查报告")): + for index, target in enumerate( + _attachment_items(first_value(note, field, default=[])), start=1 + ): + if isinstance(target, Mapping): + url = str( + first_value( + target, + "url", + "path", + "file_url", + "image_url", + default="", + ) + or "" + ).strip() + media_name = display_text( + first_value(target, "name", "file_name", "title"), + f"{label} {index}", + ) + else: + url = str(target or "").strip() + media_name = f"{label} {index}" + if not url or url in media_seen: + continue + media_seen.add(url) + media_host = QWidget() + media_layout = QHBoxLayout(media_host) + media_layout.setContentsMargins(0, 0, 0, 0) + media_layout.setSpacing(9) + if field == "tongue_images": + if _trusted_thumbnail_url(self.repository, url): + thumb = _RemoteImageButton( + url, + render_owner=self, + owner_generation=self._image_generation, + maximum_size=QSize(96, 72), + fallback_text="舌苔图片\n点击查看", + cover=True, + object_name="AiConsultTongueThumb", + parent=media_host, + ) + else: + # Keep non-whitelisted links visible and manually + # openable, but do not instantiate the network-backed + # thumbnail widget (its constructor immediately GETs). + thumb = QPushButton("舌苔图片\n点击查看", media_host) + thumb.setObjectName("AiConsultTongueThumb") + thumb.setAccessibleName("舌苔图片点击查看") + thumb.setFixedSize(QSize(96, 72)) + thumb.setProperty("loadState", "blocked") + thumb.setToolTip( + "该来源不在可信 HTTPS 缩略图域中;点击后仅外部打开。" + ) + thumb.setEnabled(safe_http_url(url) is not None) + thumb.clicked.connect( + lambda _checked=False, selected=url: self._open_safe_attachment(selected) + ) + media_layout.addWidget(thumb) + button = QPushButton(f"{media_name} · 打开") + button.setObjectName("AiConsultMediaOpen") + button.setCursor(Qt.CursorShape.PointingHandCursor) + safe = safe_http_url(url) is not None + button.setEnabled(safe) + button.setToolTip(url if safe else "仅支持安全的 HTTP(S) 附件地址") + button.clicked.connect( + lambda _checked=False, selected=url: self._open_safe_attachment(selected) + ) + media_layout.addWidget(button, 0, Qt.AlignmentFlag.AlignVCenter) + media_layout.addStretch(1) + card_layout.addWidget(media_host) + timeline_layout.addWidget(card) + if not merged: + timeline_layout.addWidget(self._inline_record_state("暂无医生随诊记录或检查附件。")) + self._insert_record_widget(pane, timeline) + error = self._section_error(notes_section) + warning = self._section_warning(notes_section) + if rejected_aggregate_notes: + aggregate_warning = ( + f"已过滤 {rejected_aggregate_notes} 条缺少当前诊单归属的聚合记录。" + ) + warning = f"{warning} {aggregate_warning}".strip() + pane.body_label.setText( # type: ignore[attr-defined] + f"血压:{_human_value(pressure)};血糖:{_human_value(glucose)};随诊记录:{len(merged)}条" + ) + if error: + message = "医生记录无读取权限。" if self._error_is_permission(error) else f"医生记录加载失败:{error}" + self._set_record_state(pane, message, "error", retry=True) + elif warning: + self._set_record_state(pane, warning, "warning") + elif merged: + self._set_record_state(pane, f"已加载 {len(merged)} 条医生记录与聚合附件。") + else: + self._set_record_state(pane, "检查指标已加载,暂无医生随诊记录。", "empty") + + def _open_safe_attachment(self, target: str) -> None: + if safe_http_url(target) is None: + show_toast(self, "附件地址无效,仅支持 HTTP(S) 远程地址。", "warning", 4200) + return + if not open_safe_http_url(target): + show_toast(self, "系统未能打开该附件。", "danger", 4200) + + def _render_prescription_record(self, section: Mapping[str, Any]) -> None: + pane = self.records["处方记录"] + self._clear_record_content(pane) + rows = list(section.get("value") or []) + error = self._section_error(section) + warning = self._section_warning(section) + can_read = has_permission(self.permissions, "cf.prescription/read", default=False) + prescription_list = QWidget() + prescription_list.setObjectName("AiConsultPrescriptionList") + list_layout = QVBoxLayout(prescription_list) + list_layout.setContentsMargins(0, 0, 0, 0) + list_layout.setSpacing(9) + for row in rows: + card = QFrame() + card.setObjectName("AiConsultPrescriptionCard") + _style_surface(card) + card_layout = QVBoxLayout(card) + card_layout.setContentsMargins(14, 12, 14, 13) + card_layout.setSpacing(8) + header = QHBoxLayout() + title = QLabel( + f"处方 {display_text(first_value(row, 'sn', 'visit_no', 'id'), '未编号')}" + ) + title.setObjectName("AiConsultRecordTitle") + header.addWidget(title) + header.addStretch(1) + open_button = QPushButton("查看详情") + open_button.setObjectName("AiConsultPrescriptionOpen") + prescription_id = _as_int(first_value(row, "id", "prescription_id", default=0)) + open_button.setProperty("prescriptionId", prescription_id) + open_button.setEnabled(can_read and prescription_id > 0) + open_button.setToolTip( + "打开完整处方详情" + if can_read + else "当前账号无 cf.prescription/read 权限,仍可查看处方摘要" + ) + open_button.clicked.connect( + lambda _checked=False, source=row: self._open_prescription_detail(source) + ) + header.addWidget(open_button) + card_layout.addLayout(header) + herbs = first_value(row, "herbs", default=[]) + herb_text = _human_value( + [ + "".join( + part + for part in ( + display_text(first_value(herb, "name", "medicine_name"), "药味"), + display_text(first_value(herb, "dosage", "amount"), ""), + display_text(first_value(herb, "unit"), "g"), + ) + if part + ) + for herb in _rows(herbs) + ], + empty="未记录药味", + ) + audit = _as_int(first_value(row, "audit_status", "status", default=-1), -1) + audit_text = display_text( + first_value(row, "audit_status_text", "status_text"), + {0: "待审核", 1: "已通过", 2: "已驳回"}.get(audit, "未记录"), + ) + voided = _truthy(first_value(row, "void_status", "is_void", default=0)) + formula_value = first_value(row, "formula_type", "prescription_type") + formula_text = { + "1": "主方", + "main": "主方", + "primary": "主方", + "2": "辅方", + "aux": "辅方", + "auxiliary": "辅方", + "secondary": "辅方", + }.get(str(formula_value or "").strip().lower(), _human_value(formula_value)) + prescription_summary = first_value( + row, + "prescription_summary", + "summary", + "prescription_name", + "name", + ) + pairs = [ + ("日期", first_value(row, "prescription_date", "create_time")), + ("诊断", first_value(row, "clinical_diagnosis", "diagnosis")), + ("方型 / 摘要", _join_meta(formula_text, _human_value(prescription_summary, empty=""))), + ("医生", first_value(row, "doctor_name", "creator_name")), + ("药味", herb_text), + ("审核 / 作废", f"{audit_text} / {'已作废' if voided else '未作废'}"), + ] + data = self._data_group("处方摘要", pairs, columns=2) + data.setObjectName("AiConsultPrescriptionSummary") + card_layout.addWidget(data) + list_layout.addWidget(card) + if not rows: + list_layout.addWidget(self._inline_record_state("暂无处方记录。")) + self._insert_record_widget(pane, prescription_list) + pane.body_label.setText( # type: ignore[attr-defined] + "\n".join( + display_text(first_value(row, "sn", "prescription_name", "name", "id"), "处方") + for row in rows + ) + or "暂无处方记录。" + ) + if error: + message = "处方摘要无读取权限。" if self._error_is_permission(error) else f"处方记录加载失败:{error}" + self._set_record_state(pane, message, "error", retry=True) + elif warning: + self._set_record_state(pane, warning, "warning") + elif rows and not can_read: + self._set_record_state(pane, "处方摘要可见;当前账号无详情读取权限。") + elif rows: + self._set_record_state(pane, f"已加载 {len(rows)} 张处方。") + else: + self._set_record_state(pane, "当前诊单暂无处方记录。", "empty") + + def _open_prescription_detail(self, prescription: Any) -> None: + if not has_permission(self.permissions, "cf.prescription/read", default=False): + show_toast(self, "当前账号无处方详情读取权限。", "warning", 4200) + return + prescription_id = _as_int(first_value(prescription, "id", "prescription_id", default=0)) + owner = first_value(prescription, "diagnosis_id", default=None) + method = getattr(self.repository, "get_prescription", None) + if prescription_id <= 0 or not callable(method): + show_toast(self, "无法加载完整处方详情。", "warning", 4200) + return + if not _exact_positive_id(owner, self.diagnosis_id): + show_toast( + self, + "处方缺少当前诊单归属,已停止打开。", + "warning", + 4200, + ) + return + self._prescription_detail_generation += 1 + detail_generation = self._prescription_detail_generation + workspace_generation = self._generation + diagnosis_id = self.diagnosis_id + self._prescription_detail_target = prescription_id + run_async( + lambda: method(prescription_id), + on_success=lambda detail: self._prescription_detail_loaded( + detail, + prescription_id, + diagnosis_id, + workspace_generation, + detail_generation, + ), + on_error=lambda error: self._prescription_detail_failed( + error, + prescription_id, + diagnosis_id, + workspace_generation, + detail_generation, + ), + ) + + def _prescription_detail_loaded( + self, + detail: Any, + prescription_id: int, + diagnosis_id: int, + workspace_generation: int, + detail_generation: int, + ) -> None: + if ( + workspace_generation != self._generation + or diagnosis_id != self.diagnosis_id + or detail_generation != self._prescription_detail_generation + or prescription_id != self._prescription_detail_target + ): + return + actual_id = first_value(detail, "id", "prescription_id", default=None) + actual_owner = first_value(detail, "diagnosis_id", default=None) + if not _exact_positive_id(actual_id, prescription_id) or not _exact_positive_id( + actual_owner, diagnosis_id + ): + show_toast( + self, + "服务端处方编号或诊单归属不匹配,已停止打开。", + "warning", + 4200, + ) + return + from .prescription import PrescriptionDetailDialog + + dialog = PrescriptionDetailDialog( + detail, + can_open_diagnosis=False, + can_open_orders=False, + parent=self, + ) + dialog.exec() + + def _prescription_detail_failed( + self, + error: Exception, + prescription_id: int, + diagnosis_id: int, + workspace_generation: int, + detail_generation: int, + ) -> None: + if ( + workspace_generation == self._generation + and diagnosis_id == self.diagnosis_id + and detail_generation == self._prescription_detail_generation + and prescription_id == self._prescription_detail_target + ): + show_toast(self, friendly_error(error), "danger", 4200) + + def _render_health_record( + self, + detail: Mapping[str, Any], + detail_error: str, + detail_warning: str, + tracking_section: Mapping[str, Any], + ) -> None: + pane = self.records["健康档案"] + self._clear_record_content(pane) + diagnosis = _as_mapping(get_value(detail, "diagnosis", default=detail) or {}) + patient = _as_mapping(get_value(detail, "patient", default={})) + for key in ( + "patient_id", + "patient_name", + "phone", + "patient_phone", + "id_card", + "gender", + "age", + "birthday", + "marital_status", + "region", + "address", + "height", + "weight", + ): + if patient.get(key) in (None, "", "—") and diagnosis.get(key) not in (None, "", "—"): + patient[key] = diagnosis[key] + can_sensitive = has_permission( + self.permissions, "tcm.diagnosis/phonePlain", default=False + ) + safe_patient = dict(patient) + if not can_sensitive: + for key in ("phone", "patient_phone", "mobile"): + if safe_patient.get(key): + safe_patient[key] = _mask_phone(safe_patient[key]) + if safe_patient.get("id_card"): + safe_patient["id_card"] = _mask_id_card(safe_patient["id_card"]) + health_grid = self._data_group( + "患者信息", + [(_field_label(key), value) for key, value in safe_patient.items()], + object_name="AiConsultHealthGrid", + columns=3, + ) + self._insert_record_widget(pane, health_grid) + health_keys = ( + "clinical_diagnosis", + "diagnosis_type", + "diabetes_discovery_year", + "height", + "weight", + "bmi", + "systolic_pressure", + "diastolic_pressure", + "fasting_blood_sugar", + "postprandial_blood_sugar", + "current_medications", + "allergy_history", + "family_history", + "smoking", + "smoking_history", + "drinking", + "drinking_history", + "diet_condition", + "sleep_condition", + "exercise_condition", + ) + health_pairs = [ + (_field_label(key), diagnosis.get(key)) + for key in health_keys + if diagnosis.get(key) not in (None, "", "—", [], {}) + ] + if health_pairs: + self._insert_record_widget( + pane, + self._data_group( + "本诊单健康概览", + health_pairs, + object_name="AiConsultDiagnosisHealthSummary", + columns=3, + ), + ) + tracking = tracking_section.get("value") + tracking = tracking if isinstance(tracking, Mapping) else {} + rejected_total = 0 + record_total = 0 + for key, title in ( + ("blood_records", "血糖 / 血压记录"), + ("diet_records", "饮食记录"), + ("exercise_records", "运动记录"), + ): + rows, rejected = _owned_rows( + first_value(tracking, key, default=[]), + self.diagnosis_id, + require_owner=True, + ) + rejected_total += rejected + record_total += len(rows) + group = QFrame() + group.setObjectName("AiConsultDataGroup") + _style_surface(group) + group_layout = QVBoxLayout(group) + group_layout.setContentsMargins(14, 12, 14, 13) + group_layout.setSpacing(8) + heading = QLabel(title) + heading.setObjectName("AiConsultRecordTitle") + group_layout.addWidget(heading) + if not rows: + group_layout.addWidget(self._inline_record_state(f"暂无{title}。")) + for row in rows: + mapping = _as_mapping(row) + pairs = [ + (_field_label(field), value) + for field, value in mapping.items() + if field not in {"id", "diagnosis_id", "patient_id"} + ] + group_layout.addWidget(self._data_group("记录", pairs, columns=3)) + self._insert_record_widget(pane, group) + tracking_error = self._section_error(tracking_section) + tracking_warning = self._section_warning(tracking_section) + error_messages: list[str] = [] + warning_messages: list[str] = [] + if detail_error: + error_messages.append( + "患者信息无读取权限。" + if self._error_is_permission(detail_error) + else f"患者信息加载失败:{detail_error}" + ) + if detail_warning: + warning_messages.append(detail_warning) + if tracking_error: + error_messages.append( + "健康跟踪记录无读取权限。" + if self._error_is_permission(tracking_error) + else f"健康跟踪记录加载失败:{tracking_error}" + ) + if tracking_warning: + warning_messages.append(tracking_warning) + if rejected_total: + warning_messages.append( + f"已过滤 {rejected_total} 条缺少当前诊单归属的健康记录。" + ) + pane.body_label.setText( # type: ignore[attr-defined] + f"患者信息 {len(safe_patient)} 项;健康记录 {record_total} 条。" + ) + if error_messages: + self._set_record_state( + pane, + " ".join(error_messages), + "error", + retry=True, + ) + elif warning_messages: + self._set_record_state( + pane, + " ".join(warning_messages), + "warning", + ) + elif not detail and not tracking: + self._set_record_state(pane, "当前诊单暂无健康档案。", "empty") + elif record_total == 0: + self._set_record_state(pane, "患者信息已加载,暂无血糖血压、饮食或运动记录。", "empty") + else: + self._set_record_state(pane, f"已加载 {record_total} 条诊单内健康记录。") + + def _submit(self) -> None: + self._ask(self.input.text()) + + def _use_suggestion(self, question: str) -> None: + self.input.setText(question) + self._ask(f"请针对该问题给出问诊话术与判断要点:{question}") + + def _ask(self, prompt: str) -> None: + text = prompt.strip() + if self._asking or not text: + return + if self.diagnosis_id <= 0: + show_toast(self, "缺少诊单编号,无法发起 AI 对话。", "warning") + return + if len(text) > 500: + show_toast(self, "问题不能超过 500 字。", "warning") + return + self._cancel_stream() + self._follow_chat = True + self.input.clear() + self._append_bubble("doctor", text, time_text=datetime.now().strftime("%H:%M")) + self._stream_bubble = self._append_bubble("ai", "") + self._stream_text = "" + self._pending_chunks.clear() + self._stream_meta.clear() + self._asking = True + self.send_button.setEnabled(False) + generation = self._generation + stream_generation = self._stream_generation + diagnosis_id = self.diagnosis_id + task = diagnosis_ai_task(text) + worker = _AiStreamWorker( + self.repository, + diagnosis_id=diagnosis_id, + prompt=text, + task=task, + ) + self._stream_worker = worker + worker.signals.event.connect( + lambda event: self._stream_event(generation, stream_generation, event) + ) + worker.signals.error.connect( + lambda error: self._stream_failed(generation, stream_generation, error) + ) + worker.signals.finished.connect( + lambda: self._stream_finished(generation, stream_generation, worker) + ) + QThreadPool.globalInstance().start(worker) + QTimer.singleShot(0, self._scroll_chat_to_bottom) + + def _stream_is_current(self, generation: int, stream_generation: int) -> bool: + return generation == self._generation and stream_generation == self._stream_generation + + def _stream_event( + self, + generation: int, + stream_generation: int, + event: Any, + ) -> None: + if not self._stream_is_current(generation, stream_generation): + return + payload = dict(event) if isinstance(event, Mapping) else {} + kind = str(payload.get("event") or "").lower() + if kind == "start": + self._stream_meta.update(payload) + return + if kind == "delta": + chunk = payload.get("text") + if isinstance(chunk, str) and chunk: + self._pending_chunks.append(chunk) + if not self._flush_timer.isActive(): + self._flush_timer.start() + return + if kind != "done": + return + self._stream_meta.update(payload) + if not self._stream_text and not self._pending_chunks: + answer = first_value(payload, "answer", "content", default="") + if answer not in (None, ""): + self._pending_chunks.append(str(answer)) + self._flush_stream_chunks() + if self._stream_bubble is not None: + if not self._stream_text: + self._stream_text = "未返回分析内容。" + self._stream_bubble.set_payload(self._stream_text) + model = display_text( + first_value(self._stream_meta, "model_label", "model_key"), + "", + ) + self._stream_bubble.set_time_text( + _join_meta(model, datetime.now().strftime("%H:%M")) + ) + + def _flush_stream_chunks(self) -> None: + if not self._pending_chunks or self._stream_bubble is None: + return + self._stream_text += "".join(self._pending_chunks) + self._pending_chunks.clear() + self._stream_bubble.set_payload(self._stream_text) + if self._follow_chat: + QTimer.singleShot(0, self._scroll_chat_to_bottom) + + def _stream_failed( + self, + generation: int, + stream_generation: int, + error: Exception, + ) -> None: + if not self._stream_is_current(generation, stream_generation): + return + self._flush_stream_chunks() + message = friendly_error(error) + if self._stream_text: + self._stream_text = f"{self._stream_text}\n\n> 生成中断:{message}" + else: + self._stream_text = message + if self._stream_bubble is not None: + self._stream_bubble.set_payload(self._stream_text) + self._stream_bubble.set_time_text(datetime.now().strftime("%H:%M")) + + def _stream_finished( + self, + generation: int, + stream_generation: int, + worker: _AiStreamWorker, + ) -> None: + if not self._stream_is_current(generation, stream_generation): + return + if self._stream_worker is worker: + self._stream_worker = None + self._asking = False + self.send_button.setEnabled(True) + + def _cancel_stream(self) -> None: + self._stream_generation += 1 + self._flush_timer.stop() + if self._stream_worker is not None: + self._stream_worker.cancel() + self._stream_worker = None + self._stream_bubble = None + self._pending_chunks.clear() + self._stream_text = "" + self._stream_meta.clear() + self._asking = False + self.send_button.setEnabled(True) + + def closeEvent(self, event: Any) -> None: # type: ignore[override] + self._generation += 1 + self._image_generation += 1 + self._cancel_stream() + super().closeEvent(event) + + def _open_full_record(self) -> None: + if self.diagnosis_id <= 0: + return + from .diagnosis import DiagnosisDialog + + dialog = DiagnosisDialog(self.repository, self) + dialog.open_view_only(self.diagnosis_id, seed=self._seed or self._detail) + dialog.exec() + + +def present_ai_consult( + repository: Any, + permissions: Any, + parent: QWidget | None, + *, + diagnosis_id: int, + patient_id: int = 0, + seed: Any = None, + source_title: str = "问诊列表", +) -> None: + if not can_open_ai_consult(permissions): + if parent is not None: + show_toast(parent, "当前账号没有使用 AI 问诊助手的权限。", "warning") + return + if diagnosis_id <= 0: + if parent is not None: + show_toast(parent, "缺少诊单编号,无法打开 AI 对话。", "warning") + return + dialog = AiConsultDialog(repository, permissions, parent=parent) + dialog.open_for( + diagnosis_id=diagnosis_id, + patient_id=patient_id, + seed=seed, + source_title=source_title, + ) + dialog.exec() + + +__all__ = ["AiConsultDialog", "can_open_ai_consult", "present_ai_consult", "render_chat_payload"] diff --git a/app/src/doctor_workstation/ui/dialogs/prescription.py b/app/src/doctor_workstation/ui/dialogs/prescription.py index 2ae60f24f..f9123216e 100644 --- a/app/src/doctor_workstation/ui/dialogs/prescription.py +++ b/app/src/doctor_workstation/ui/dialogs/prescription.py @@ -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.""" diff --git a/app/src/doctor_workstation/ui/pages/appointments.py b/app/src/doctor_workstation/ui/pages/appointments.py index b8de63b9c..827a4e6b9 100644 --- a/app/src/doctor_workstation/ui/pages/appointments.py +++ b/app/src/doctor_workstation/ui/pages/appointments.py @@ -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: diff --git a/app/src/doctor_workstation/ui/pages/consultations.py b/app/src/doctor_workstation/ui/pages/consultations.py index 8423ed48c..85fbd877f 100644 --- a/app/src/doctor_workstation/ui/pages/consultations.py +++ b/app/src/doctor_workstation/ui/pages/consultations.py @@ -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() diff --git a/app/src/doctor_workstation/ui/pages/patients.py b/app/src/doctor_workstation/ui/pages/patients.py index 937ae1b2f..a1309bdae 100644 --- a/app/src/doctor_workstation/ui/pages/patients.py +++ b/app/src/doctor_workstation/ui/pages/patients.py @@ -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") diff --git a/app/src/doctor_workstation/ui/pages/prescription_library.py b/app/src/doctor_workstation/ui/pages/prescription_library.py index 9e06a4c07..63fd6d463 100644 --- a/app/src/doctor_workstation/ui/pages/prescription_library.py +++ b/app/src/doctor_workstation/ui/pages/prescription_library.py @@ -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) diff --git a/app/src/doctor_workstation/ui/pages/prescriptions.py b/app/src/doctor_workstation/ui/pages/prescriptions.py index 3a4d63bc7..e979a9723 100644 --- a/app/src/doctor_workstation/ui/pages/prescriptions.py +++ b/app/src/doctor_workstation/ui/pages/prescriptions.py @@ -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", diff --git a/app/src/doctor_workstation/ui/pages/reception.py b/app/src/doctor_workstation/ui/pages/reception.py index ccddc7579..c2b790cbc 100644 --- a/app/src/doctor_workstation/ui/pages/reception.py +++ b/app/src/doctor_workstation/ui/pages/reception.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re from collections.abc import Mapping, Sequence from contextlib import suppress from copy import deepcopy @@ -13,6 +14,7 @@ from typing import Any from PySide6.QtCore import QDate, QPoint, QPointF, QRectF, QSize, Qt, QTimer, QUrl, Signal from PySide6.QtGui import ( + QBrush, QColor, QDesktopServices, QFont, @@ -22,7 +24,6 @@ from PySide6.QtGui import ( QPainterPath, QPen, QPixmap, - QPolygonF, QTextCursor, QTextLayout, QTextOption, @@ -53,7 +54,10 @@ from PySide6.QtWidgets import ( QWidget, ) +from ..diagnosis_drawer import DailyRecordPanel +from ..diagnosis_editors import FlowLayout from ..dialogs import DiagnosisDialog +from ..dialogs.ai_consult import present_ai_consult from ..dialogs.prescription_ai import ( can_open_diagnosis_ai_report, can_use_diagnosis_ai_assistant, @@ -528,8 +532,6 @@ QWidget#ReceptionPage QScrollArea#ReceptionDetailScroll QScrollBar::sub-line:ver } QWidget#ReceptionPage QFrame#ReceptionAiAnalysisCard, QWidget#ReceptionPage QFrame#ReceptionAiAssistantCard { - min-height: 470px; - max-height: 520px; background: qlineargradient( x1: 0, y1: 0, x2: 1, y2: 1, stop: 0 #F5F8FF, stop: 0.58 #FAFCFF, stop: 1 #FFFFFF @@ -647,10 +649,6 @@ QWidget#ReceptionPage QWidget#ReceptionAiAnalysisSections { background-color: transparent; border: 0; } -QWidget#ReceptionPage QStackedWidget#ReceptionAiAnalysisStack QScrollArea { - background-color: transparent; - border: 0; -} QWidget#ReceptionPage QFrame#ReceptionAiAnalysisSection { background-color: transparent; border: 0; @@ -682,20 +680,33 @@ QWidget#ReceptionPage QLabel[receptionAiValue="true"][secondary="true"] { } QWidget#ReceptionPage QLabel[receptionRiskChip="true"] { min-height: 22px; - max-height: 22px; - padding: 0 8px; - color: #5469F0; - background-color: #EEF1FF; - border: 0; + padding: 3px 8px; + color: #287B66; + background-color: #ECF8F3; + border: 1px solid #D7EEE5; border-radius: 8px; font-size: 11px; font-weight: 500; } -QWidget#ReceptionPage QLabel[receptionRiskChip="true"][riskLevel="high"], -QWidget#ReceptionPage QLabel[receptionRiskChip="true"][riskLevel="medium"], -QWidget#ReceptionPage QLabel[receptionRiskChip="true"][riskLevel="low"] { - color: #4C6FE8; - background-color: #EEF3FF; +QWidget#ReceptionPage QLabel[receptionRiskChip="true"][riskLevel="high"] { + color: #B13E4D; + background-color: #FFF0F2; + border-color: #F3D1D7; +} +QWidget#ReceptionPage QLabel[receptionRiskChip="true"][riskLevel="medium"] { + color: #9A681D; + background-color: #FFF7E8; + border-color: #F1E0BC; +} +QWidget#ReceptionPage QLabel[receptionRiskOverflow="true"] { + min-height: 22px; + padding: 3px 8px; + color: #657391; + background-color: #F2F5FA; + border: 1px solid #E0E5EF; + border-radius: 8px; + font-size: 11px; + font-weight: 600; } QWidget#ReceptionPage QFrame#ReceptionAiState { background-color: #F7F9FF; @@ -766,6 +777,137 @@ QWidget#ReceptionPage QPushButton#ReceptionAiSendButton { QWidget#ReceptionPage QPushButton#ReceptionAiSendButton:hover { background-color: #4458E0; } +QWidget#ReceptionPage QFrame#ReceptionDailyRecordsCard, +QWidget#ReceptionPage QFrame#ReceptionFollowupCard { + background-color: #FFFFFF; + border: 1px solid #E4E9F6; + border-radius: 11px; +} +QWidget#ReceptionPage QWidget#ReceptionDailyToolbar { + background-color: transparent; + border: 0; +} +QWidget#ReceptionPage QLabel#ReceptionDailyTitle, +QWidget#ReceptionPage QLabel#ReceptionFollowupTitle { + color: #17264D; + font-size: 16px; + font-weight: 700; +} +QWidget#ReceptionPage QLabel#ReceptionDailySubtitle, +QWidget#ReceptionPage QLabel#ReceptionFollowupSubtitle { + color: #6F7FA3; + font-size: 12px; + font-weight: 400; +} +QWidget#ReceptionPage QPushButton[receptionDailyRange="true"] { + min-height: 30px; + max-height: 30px; + padding: 0 13px; + color: #53627F; + background-color: #FFFFFF; + border: 1px solid #DDE4F2; + border-radius: 7px; + font-size: 12px; + font-weight: 500; +} +QWidget#ReceptionPage QPushButton[receptionDailyRange="true"]:hover { + color: #4057D6; + background-color: #F6F8FF; + border-color: #BFC9FF; +} +QWidget#ReceptionPage QPushButton[receptionDailyRange="true"]:checked { + color: #FFFFFF; + background-color: #5469F0; + border-color: #5469F0; + font-weight: 700; +} +QWidget#ReceptionPage QDateEdit[receptionDailyDate="true"] { + min-height: 30px; + max-height: 30px; + padding: 0 9px; + color: #2D3A5C; + background-color: #FFFFFF; + border: 1px solid #DDE4F2; + border-radius: 7px; + font-size: 12px; +} +QWidget#ReceptionPage QPushButton#ReceptionDailyRefreshButton { + min-height: 30px; + max-height: 30px; + padding: 0 13px; + color: #4057D6; + background-color: #F5F7FF; + border: 1px solid #D5DCFF; + border-radius: 7px; + font-size: 12px; + font-weight: 600; +} +QWidget#ReceptionPage QPushButton#ReceptionDailyRefreshButton:hover { + color: #FFFFFF; + background-color: #5469F0; + border-color: #5469F0; +} +QWidget#ReceptionPage QFrame#ReceptionDailyRecordsState { + background-color: #F5F7FF; + border: 1px solid #E0E6F8; + border-radius: 8px; +} +QWidget#ReceptionPage QFrame#ReceptionDailyRecordsState[kind="danger"] { + background-color: #FFF5F6; + border-color: #F3D1D6; +} +QWidget#ReceptionPage QFrame#ReceptionDailyRecordsState[kind="warning"] { + background-color: #FFF9EF; + border-color: #F0DFC3; +} +QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable { + color: #263553; + background-color: #FFFFFF; + alternate-background-color: #FBFCFF; + border: 1px solid #E3E8F3; + border-radius: 8px; + gridline-color: #E7EBF4; + font-size: 12px; + outline: 0; +} +QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable::item { + padding: 6px 8px; + border: 0; +} +QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable QHeaderView::section { + min-height: 34px; + color: #53627F; + background-color: #F5F7FC; + border: 0; + border-right: 1px solid #E3E8F3; + border-bottom: 1px solid #E3E8F3; + font-size: 12px; + font-weight: 600; +} +QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable QScrollBar:horizontal, +QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable QScrollBar:vertical { + background-color: #F3F5FA; + border: 0; + border-radius: 4px; +} +QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable QScrollBar:horizontal { + height: 9px; +} +QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable QScrollBar:vertical { + width: 9px; +} +QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable QScrollBar::handle:horizontal, +QWidget#ReceptionPage QTableWidget#ReceptionDailyRecordsTable QScrollBar::handle:vertical { + min-width: 36px; + min-height: 36px; + background-color: #B8C3DA; + border-radius: 4px; +} +QWidget#ReceptionPage QLabel#ReceptionFollowupText { + color: #334260; + font-size: 13px; + font-weight: 400; +} QWidget#ReceptionPage QLabel#ReceptionQueueSummary { color: #8A93A8; font-size: 11px; @@ -875,17 +1017,17 @@ QDialog#ReceptionAiAnalysisDialog QLabel[dialogAiBody="true"] { QDialog#ReceptionAiAnalysisDialog QLabel[dialogAiRisk="true"] { min-height: 28px; padding: 4px 10px; - color: #2595A5; - background-color: #EAF9FC; - border: 1px solid #D5F2F6; + color: #B13E4D; + background-color: #FFF0F2; + border: 1px solid #F3D1D7; border-radius: 7px; font-size: 12px; font-weight: 600; } QDialog#ReceptionAiAnalysisDialog QLabel[dialogAiRisk="true"][riskLevel="medium"] { - color: #388FAF; - background-color: #EDF7FC; - border-color: #DBEEF7; + color: #9A681D; + background-color: #FFF7E8; + border-color: #F1E0BC; } QDialog#ReceptionAiAnalysisDialog QLabel[dialogAiRisk="true"][riskLevel="low"] { color: #279776; @@ -1028,6 +1170,101 @@ def _summary_value(value: object, *nested_keys: str) -> str: return text if text not in {"{}", "[]"} else "" +_AI_NARRATIVE_KEYS = ( + "text", + "content", + "description", + "value", + "label", + "summary", + "title", + "name", +) +_AI_LIST_MARKER = ( + r"(?:\d{1,3}[.、.](?!\d)|[((]\d{1,3}[))]|" + r"[一二三四五六七八九十百]+、|[•●▪◦]|-(?=[ \t\u3000]))" +) +_AI_LIST_LINE_RE = re.compile(rf"^\s*{_AI_LIST_MARKER}") +_AI_INLINE_LIST_RE = re.compile( + rf"(?P[。!?;:;: \t\u3000])" + rf"(?P{_AI_LIST_MARKER})[ \t\u3000]*" +) + + +def _normalize_ai_narrative_string(value: object) -> str: + """Preserve authored paragraphs while safely exposing escaped line breaks.""" + + text = str(value or "") + if not text.strip() or text.strip() in {"{}", "[]"}: + return "" + had_real_break = any(separator in text for separator in ("\r", "\n", "\u2028", "\u2029")) + text = text.replace("\r\n", "\n").replace("\r", "\n") + text = text.replace("\u2028", "\n").replace("\u2029", "\n") + if not had_real_break: + text = text.replace(r"\r\n", "\n").replace(r"\n", "\n").replace(r"\r", "\n") + + normalized_lines: list[str] = [] + for raw_line in text.split("\n"): + line = re.sub(r"[\t\u3000 ]+", " ", raw_line).strip() + if line: + line = _AI_INLINE_LIST_RE.sub( + lambda match: ( + (match.group("prefix") if match.group("prefix") not in " \t\u3000" else "") + + "\n" + + match.group("marker") + + " " + ), + line, + ) + for split_line in line.split("\n"): + split_line = split_line.strip() + if split_line: + split_line = re.sub( + rf"^({_AI_LIST_MARKER})[ \t\u3000]*", + r"\1 ", + split_line, + ).rstrip() + normalized_lines.append(split_line) + else: + normalized_lines.append("") + + compacted: list[str] = [] + for line in normalized_lines: + if not line and (not compacted or not compacted[-1]): + continue + compacted.append(line) + while compacted and not compacted[-1]: + compacted.pop() + return "\n".join(compacted) + + +def _ai_narrative_text(value: object) -> str: + """Return structured AI prose without flattening arrays into one sentence.""" + + if value in (None, ""): + return "" + if isinstance(value, Mapping): + for key in _AI_NARRATIVE_KEYS: + if key not in value: + continue + text = _ai_narrative_text(value.get(key)) + if text: + return text + return "" + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + items: list[str] = [] + for item in value: + text = _ai_narrative_text(item) + if not text: + continue + first_line = next((line for line in text.splitlines() if line.strip()), "") + if first_line and not _AI_LIST_LINE_RE.match(first_line): + text = f"• {text}" + items.append(text) + return "\n".join(items) + return _normalize_ai_narrative_string(value) + + def _queue_condition_summary(record: Any) -> str: """Build the queue's disease/complaint summary from scalar or nested DTOs.""" @@ -1082,12 +1319,19 @@ def _queue_time_text(record: Any) -> str: def _queue_glucose_value(record: Any) -> object: + diagnosis = first_value(record, "diagnosis", "case_record", default={}) or {} return first_value( record, "fasting_blood_sugar", "fasting_glucose", "blood_sugar", - default=None, + default=first_value( + diagnosis, + "fasting_blood_sugar", + "fasting_glucose", + "blood_sugar", + default=None, + ), ) @@ -1176,6 +1420,11 @@ def _painted_reception_tab_icon(kind: str, size: int = 16) -> QIcon: elif kind == "meds": painter.drawRoundedRect(QRectF(3, 5.5, 10, 5), 2.4, 2.4) painter.drawLine(QPointF(8, 5.5), QPointF(8, 10.5)) + elif kind == "daily": + painter.drawRoundedRect(QRectF(2.5, 2.5, 11, 11), 1.6, 1.6) + painter.drawLine(QPointF(5.2, 5.5), QPointF(10.8, 5.5)) + painter.drawLine(QPointF(5.2, 8), QPointF(10.8, 8)) + painter.drawLine(QPointF(5.2, 10.5), QPointF(8.8, 10.5)) elif kind == "followup": painter.drawRoundedRect(QRectF(2.5, 3.5, 11, 10.5), 2, 2) painter.drawLine(QPointF(2.5, 7), QPointF(13.5, 7)) @@ -1242,13 +1491,16 @@ def _normalize_ai_risks(value: object) -> list[dict[str, str]]: """Keep every valid structured risk while constraining its visual severity.""" normalized: list[dict[str, str]] = [] - for item in _sequence(value): - if not isinstance(item, Mapping): - continue - label = _summary_value(item.get("label"), "label") + items = [value] if isinstance(value, Mapping) else _sequence(value) + for item in items: + if isinstance(item, Mapping): + label = _ai_narrative_text(item.get("label")) + level = str(item.get("level") or "low").strip().lower() + else: + label = _ai_narrative_text(item) + level = "low" if not label: continue - level = str(item.get("level") or "low").strip().lower() if level not in {"high", "medium", "low"}: level = "low" normalized.append({"label": label, "level": level}) @@ -1316,7 +1568,7 @@ def _normalize_patient_report(payload: Mapping[str, Any]) -> dict[str, Any] | No def first_nonempty(*values: Any) -> Any: return next((value for value in values if value not in (None, "")), None) - diagnosis = _summary_value( + diagnosis = _ai_narrative_text( first_nonempty( payload.get("diagnosis_advice"), payload.get("diagnosis"), @@ -1325,7 +1577,7 @@ def _normalize_patient_report(payload: Mapping[str, Any]) -> dict[str, Any] | No structured.get("summary"), ) ) - treatment = _summary_value( + treatment = _ai_narrative_text( first_nonempty( payload.get("treatment_advice"), structured.get("treatment_advice"), @@ -1641,80 +1893,169 @@ class _NoteAttachmentPreview(QPushButton): class _SpacedTextLabel(QLabel): - """Plain-text label that paints wrapped lines with open leading.""" - - _LINE_HEIGHT = 1.65 + """Shrinkable plain-text label using Qt's native, reliable word wrapping.""" def __init__(self, text: str = "", parent: QWidget | None = None) -> None: super().__init__(text, parent) self.setWordWrap(True) self.setTextFormat(Qt.TextFormat.PlainText) self.setMinimumWidth(0) - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) - - def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt override - return True - - def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt override - return self._layout_height(max(32, width)) - - def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt override - return QSize(32, self._line_step()) - - def sizeHint(self) -> QSize: # noqa: N802 - Qt override - width = self.width() if self.width() > 1 else 280 - return QSize(width, self.heightForWidth(width)) + self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) def setText(self, text: str) -> None: # noqa: N802 - Qt override super().setText(text) self.updateGeometry() + +class _AiPreviewLabel(QLabel): + """Width-aware multi-line preview that keeps the complete report separately.""" + + def __init__( + self, + text: str = "", + parent: QWidget | None = None, + *, + maximum_lines: int = 3, + ) -> None: + super().__init__("", parent) + self._full_text = "" + self._maximum_lines = max(1, maximum_lines) + self._refreshing = False + self.setWordWrap(True) + self.setTextFormat(Qt.TextFormat.PlainText) + self.setMinimumWidth(0) + self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + self.setText(text) + + def fullText(self) -> str: # noqa: N802 - Qt-style compatibility helper + return self._full_text + + def setText(self, text: str) -> None: # noqa: N802 - Qt override + self._full_text = str(text or "") + self._refresh_preview() + def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt override super().resizeEvent(event) - self.update() + self._refresh_preview() - def _line_step(self) -> int: - metrics = QFontMetrics(self.font()) - return max(metrics.lineSpacing() + 4, int(round(metrics.height() * self._LINE_HEIGHT))) + def _refresh_preview(self) -> None: + if self._refreshing: + return + self._refreshing = True + try: + source = self._full_text or "—" + margins = self.contentsMargins() + width = self.contentsRect().width() + if width < 32: + width = max(220, self.width() - margins.left() - margins.right()) - def _layout_height(self, width: int) -> int: - margins = self.contentsMargins() - inner = max(1, width - margins.left() - margins.right()) - return margins.top() + margins.bottom() + self._line_count(inner) * self._line_step() + option = QTextOption() + option.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere) + pieces: list[str] = [] + truncated = False + logical_lines = source.split("\n") + for logical_index, logical_line in enumerate(logical_lines): + if not logical_line: + pieces.append("") + else: + text_layout = QTextLayout(logical_line, self.font()) + text_layout.setTextOption(option) + text_layout.beginLayout() + while True: + line = text_layout.createLine() + if not line.isValid(): + break + line.setLineWidth(width) + pieces.append( + logical_line[ + line.textStart() : line.textStart() + line.textLength() + ] + ) + if len(pieces) > self._maximum_lines: + truncated = True + break + text_layout.endLayout() + if truncated: + break + if ( + len(pieces) >= self._maximum_lines + and logical_index < len(logical_lines) - 1 + ): + truncated = True + break - def _line_count(self, width: int) -> int: - layout = self._make_layout(max(1, width)) - return max(1, layout.lineCount()) + pieces = pieces[: self._maximum_lines] + if not pieces: + pieces = ["—"] + if truncated: + last = pieces[-1].rstrip() + pieces[-1] = QFontMetrics(self.font()).elidedText( + f"{last}…", + Qt.TextElideMode.ElideRight, + width, + ) + preview = "\n".join(pieces) if truncated else source + QLabel.setText(self, preview) + self.setToolTip(source if truncated else "") + line_height = QFontMetrics(self.font()).lineSpacing() + self.setMaximumHeight(line_height * self._maximum_lines + 4) + self.updateGeometry() + finally: + self._refreshing = False - def _make_layout(self, width: int) -> QTextLayout: - layout = QTextLayout(self.text() or " ", self.font()) - option = QTextOption() - option.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere) - option.setAlignment(self.alignment()) - layout.setTextOption(option) - layout.beginLayout() - y = 0.0 - step = self._line_step() - while True: - line = layout.createLine() - if not line.isValid(): - break - line.setLineWidth(width) - line.setPosition(QPointF(0, y)) - y += step - layout.endLayout() - return layout - def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override - del event - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.TextAntialiasing) - painter.setPen(self.palette().color(self.foregroundRole())) - painter.setClipRect(self.rect()) - margins = self.contentsMargins() - width = max(1, self.width() - margins.left() - margins.right()) - layout = self._make_layout(width) - layout.draw(painter, QPointF(margins.left(), margins.top())) +class _AiFlowHost(QWidget): + """Flow-layout host that reserves the wrapped rows at its current width.""" + + def __init__( + self, + parent: QWidget | None = None, + *, + horizontal_spacing: int = 7, + vertical_spacing: int = 7, + ) -> None: + super().__init__(parent) + self.setMinimumWidth(0) + policy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + policy.setHeightForWidth(True) + self.setSizePolicy(policy) + self.flow = FlowLayout( + self, + horizontal_spacing=horizontal_spacing, + vertical_spacing=vertical_spacing, + ) + + def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt virtual + return True + + def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt virtual + usable = max(48, int(width)) if int(width) > 1 else 220 + return max(0, self.flow.heightForWidth(usable)) + + def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt virtual + return QSize(0, 0) + + def sizeHint(self) -> QSize: # noqa: N802 - Qt virtual + width = self.width() if self.width() > 1 else 220 + return QSize(width, self.heightForWidth(width)) + + def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt override + super().resizeEvent(event) + self.syncFlowHeight() + + def showEvent(self, event: Any) -> None: # noqa: N802 - Qt override + super().showEvent(event) + self.syncFlowHeight() + + def syncFlowHeight(self) -> None: # noqa: N802 - Qt-style compatibility helper + needed = self.heightForWidth(self.width()) + if self.minimumHeight() == needed and self.maximumHeight() == needed: + return + self.setFixedHeight(needed) + self.updateGeometry() + parent = self.parentWidget() + if parent is not None: + parent.updateGeometry() class _ReceptionSparkIcon(QWidget): @@ -2009,8 +2350,8 @@ class _ReceptionAiAnalysisDialog(QDialog): self.setWindowTitle("患者级 AI 诊断报告") self.setAccessibleName("患者级 AI 诊断报告") self.setModal(True) - self.resize(780, 680) - self.setMinimumSize(560, 420) + self.resize(920, 760) + self.setMinimumSize(720, 560) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self.setStyleSheet(RECEPTION_AI_DIALOG_QSS) @@ -2129,13 +2470,16 @@ class _ReceptionAiAnalysisDialog(QDialog): caption_label = QLabel(caption, section) caption_label.setProperty("dialogAiCaption", True) copy.addWidget(caption_label) - value_label = _SpacedTextLabel(text or "—", section) + value_label = QLabel(text or "—", section) value_label.setObjectName(object_name) value_label.setProperty("dialogAiBody", True) + value_label.setTextFormat(Qt.TextFormat.PlainText) + value_label.setWordWrap(True) + value_label.setMinimumWidth(0) value_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) value_label.setSizePolicy( + QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred, - QSizePolicy.Policy.Minimum, ) copy.addWidget(value_label) section_layout.addLayout(copy, 1) @@ -2165,11 +2509,13 @@ class _ReceptionAiAnalysisDialog(QDialog): risk_caption = QLabel("风险评估", risk_section) risk_caption.setProperty("dialogAiCaption", True) risk_copy.addWidget(risk_caption) - risk_items = QWidget(risk_section) + risk_items = _AiFlowHost( + risk_section, + horizontal_spacing=7, + vertical_spacing=7, + ) risk_items.setObjectName("ReceptionAiAnalysisDialogRiskItems") - risk_items_layout = QVBoxLayout(risk_items) - risk_items_layout.setContentsMargins(0, 0, 0, 0) - risk_items_layout.setSpacing(7) + risk_items_layout = risk_items.flow self.risk_items = risk_items self.risk_items_layout = risk_items_layout risk_copy.addWidget(risk_items) @@ -2312,10 +2658,10 @@ class _ReceptionAiAnalysisDialog(QDialog): self.active_history_index = history_index self.payload = payload self.diagnosis_label.setText( - _summary_value(_analysis_value(payload, "diagnosis_advice")) or "—" + _ai_narrative_text(_analysis_value(payload, "diagnosis_advice")) or "—" ) self.treatment_label.setText( - _summary_value(_analysis_value(payload, "treatment_advice")) or "—" + _ai_narrative_text(_analysis_value(payload, "treatment_advice")) or "—" ) _clear_ai_layout(self.risk_items_layout) risks = _normalize_ai_risks(_analysis_value(payload, "risk_assessment")) @@ -2325,9 +2671,17 @@ class _ReceptionAiAnalysisDialog(QDialog): label = QLabel(risk["label"], self.risk_items) label.setTextFormat(Qt.TextFormat.PlainText) label.setWordWrap(True) + label.setMinimumWidth(0) + label.setMaximumWidth(340) + label.setSizePolicy( + QSizePolicy.Policy.Maximum, + QSizePolicy.Policy.Preferred, + ) label.setProperty("dialogAiRisk", True) label.setProperty("riskLevel", risk["level"]) self.risk_items_layout.addWidget(label) + self.risk_items_layout.invalidate() + self.risk_items.syncFlowHeight() model = _summary_value( _analysis_value(payload, "model_label") @@ -2722,6 +3076,171 @@ class QueueRow(QWidget): painter.drawRoundedRect(QRectF(1.2, 8, 3.2, self.height() - 16), 1.6, 1.6) +class ReceptionDailyRecordsPanel(DailyRecordPanel): + """Read-only reception matrix backed by the diagnosis tracking endpoints.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("ReceptionDailyRecordsCard") + self.setAccessibleName("患者日常记录") + self._patient_age: int | None = None + self.set_editable(False, record_editable=False) + + panel_layout = self.layout() + if isinstance(panel_layout, QVBoxLayout): + panel_layout.setContentsMargins(16, 14, 16, 16) + panel_layout.setSpacing(10) + heading = QWidget(self) + heading_layout = QHBoxLayout(heading) + heading_layout.setContentsMargins(0, 0, 0, 0) + heading_layout.setSpacing(12) + heading_text = QVBoxLayout() + heading_text.setContentsMargins(0, 0, 0, 0) + heading_text.setSpacing(2) + title = QLabel("日常记录", heading) + title.setObjectName("ReceptionDailyTitle") + title.setTextFormat(Qt.TextFormat.PlainText) + heading_text.addWidget(title) + subtitle = QLabel("血糖、血压、用药、饮食、运动与跟踪备注", heading) + subtitle.setObjectName("ReceptionDailySubtitle") + subtitle.setTextFormat(Qt.TextFormat.PlainText) + heading_text.addWidget(subtitle) + heading_layout.addLayout(heading_text) + heading_layout.addStretch(1) + panel_layout.insertWidget(0, heading) + + self.toolbar_host.setObjectName("ReceptionDailyToolbar") + for mode, button in self.range_buttons.items(): + button.setObjectName( + { + "7": "ReceptionDailyRange7Button", + "30": "ReceptionDailyRange30Button", + "custom": "ReceptionDailyRangeCustomButton", + }[mode] + ) + button.setProperty("receptionDailyRange", True) + button.setAccessibleName(button.text()) + self.start_date.setObjectName("ReceptionDailyCustomStart") + self.end_date.setObjectName("ReceptionDailyCustomEnd") + for editor, name in ( + (self.start_date, "日常记录开始日期"), + (self.end_date, "日常记录结束日期"), + ): + editor.setProperty("receptionDailyDate", True) + editor.setAccessibleName(name) + self.refresh_button.setObjectName("ReceptionDailyRefreshButton") + self.refresh_button.setAccessibleName("刷新日常记录") + self.refresh_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.state.setObjectName("ReceptionDailyRecordsState") + self.state.setAccessibleName("日常记录加载状态") + self.matrix.setObjectName("ReceptionDailyRecordsTable") + self.matrix.setAccessibleName("患者日常记录矩阵") + self.matrix.setMinimumHeight(330) + self.matrix.setMaximumHeight(460) + self.matrix.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + + for object_name in ("DiagnosisTrendCard", "DiagnosisTodoCard"): + child = self.findChild(QFrame, object_name) + if child is not None: + child.hide() + self.clear_with_message("选择患者后加载最近 7 天日常记录。") + + def set_patient_age(self, age: Any) -> None: + parsed = _as_int(age, None) + self._patient_age = parsed if parsed is not None and parsed >= 0 else None + + def clear_with_message(self, message: str) -> None: + super().clear() + self.state.show_message(message, "info") + + def set_loading(self, loading: bool) -> None: + super().set_loading(loading) + for button in self.range_buttons.values(): + button.setEnabled(not loading) + self.start_date.setEnabled(not loading) + self.end_date.setEnabled(not loading) + if loading: + self.state.show_message("正在从后端读取日常记录与跟踪备注…", "info") + + def _glucose_is_high(self, metric: str, number: float | None) -> bool: + if number is None or self._patient_age is None: + return False + older = self._patient_age >= 50 + threshold = 8.0 if older and metric == "fasting" else 7.0 + if metric in {"postprandial", "other"}: + threshold = 10.0 if older else 9.0 + return number >= threshold + + @staticmethod + def _daily_value(record: Any, *keys: str) -> Any: + return first_value(record or {}, *keys, default=None) + + def set_data(self, window: Any, notes: Sequence[Any]) -> None: + """Render canonical production fields and the admin age thresholds.""" + + super().set_data(window, notes) + blood_rows = list(first_value(window, "blood_records", default=[]) or []) + blood = self._blood_date_map(blood_rows) + dates = self._dates() + for column, day in enumerate(dates, 1): + record = blood.get(day) + patient_self = bool(self._daily_value(record, "has_patient_self")) + for row, metric in enumerate(("fasting", "postprandial", "other", "bp")): + item = self.matrix.item(row, column) + if item is None: + continue + high = False + value = "—" + if metric == "fasting": + raw = self._daily_value(record, "fasting_blood_sugar") + number = self._number(raw) + value = display_text(raw) if number is not None else "—" + high = self._glucose_is_high(metric, number) + elif metric == "postprandial": + raw = self._daily_value(record, "postprandial_blood_sugar") + number = self._number(raw) + value = display_text(raw) if number is not None else "—" + high = self._glucose_is_high(metric, number) + elif metric == "other": + raw = self._daily_value(record, "other_blood_sugar") + number = self._number(raw) + value = display_text(raw) if number is not None else "—" + high = self._glucose_is_high(metric, number) + else: + systolic_raw = self._daily_value(record, "systolic_pressure") + diastolic_raw = self._daily_value(record, "diastolic_pressure") + systolic = self._number(systolic_raw) + diastolic = self._number(diastolic_raw) + if systolic is not None or diastolic is not None: + value = f"{display_text(systolic_raw)}/{display_text(diastolic_raw)}" + high = bool((systolic or 0) > 140 or (diastolic or 0) > 90) + if value != "—" and patient_self: + value = f"{value} · 自录" + if value != "—" and high: + value = f"{value} ↑" + item.setText(value) + item.setData( + Qt.ItemDataRole.UserRole, + {"date": day, "metric": metric, "high": high, "patient_self": patient_self}, + ) + item.setToolTip(value if value != "—" else "") + font = item.font() + font.setWeight(QFont.Weight.Bold if high else QFont.Weight.Normal) + item.setFont(font) + item.setForeground(QBrush(QColor("#D94856" if high else "#334260"))) + item.setBackground( + QBrush( + QColor( + "#FFF1F3" + if high + else "#F0F4FF" + if patient_self and value != "—" + else Qt.GlobalColor.transparent + ) + ) + ) + + class ReceptionPage(QWidget): """Run the complete same-day reception workflow without blocking Qt.""" @@ -2756,6 +3275,11 @@ class ReceptionPage(QWidget): self._queue_query_key: tuple[Any, ...] | None = None self._detail_loading = False self._detail_requests: set[tuple[int, int]] = set() + self._daily_generation = 0 + self._daily_loading = False + self._daily_diagnosis_id: int | None = None + self._daily_requests: set[tuple[int, int, int]] = set() + self._daily_tracking_fallback: list[Any] = [] self._ai_analysis_generation = 0 self._ai_analysis_loading = False self._ai_analysis_diagnosis_id: int | None = None @@ -2987,7 +3511,7 @@ class ReceptionPage(QWidget): self.history_button.setObjectName("ReceptionHistoryButton") self.history_button.setIcon(_painted_reception_action_icon("info", "#3F4E75")) self.history_button.setIconSize(QSize(14, 14)) - self.history_button.clicked.connect(lambda: self.detail_tabs.setCurrentIndex(3)) + self.history_button.clicked.connect(lambda: self.detail_tabs.setCurrentIndex(4)) patient_head.addWidget(self.history_button) self.more_button = QPushButton("更多", hero) self.more_button.setObjectName("ReceptionMoreButton") @@ -3000,6 +3524,9 @@ class ReceptionPage(QWidget): ai_action = more_menu.addAction("查看 AI 报告") ai_action.setVisible(self._can_ai_report) ai_action.triggered.connect(self._open_ai_report) + consult_action = more_menu.addAction("AI 分析") + consult_action.setVisible(self._can_ai_assistant) + consult_action.triggered.connect(self._open_ai_consult) self.more_button.setMenu(more_menu) patient_head.addWidget(self.more_button) @@ -3022,6 +3549,10 @@ class ReceptionPage(QWidget): self.ai_button.setProperty("variant", "secondary") self.ai_button.clicked.connect(self._open_ai_report) self.ai_button.setVisible(self._can_ai_report) + self.ai_consult_button = QPushButton("AI 分析", action_compat) + self.ai_consult_button.setProperty("variant", "secondary") + self.ai_consult_button.clicked.connect(self._open_ai_consult) + self.ai_consult_button.setVisible(self._can_ai_assistant) hero_layout.addLayout(patient_head) detail_layout.addWidget(hero) @@ -3044,6 +3575,7 @@ class ReceptionPage(QWidget): ("问诊信息", "consult"), ("检查报告", "report"), ("用药记录", "meds"), + ("日常记录", "daily"), ("随访记录", "followup"), ("健康数据", "health"), ): @@ -3442,17 +3974,35 @@ class ReceptionPage(QWidget): self.diagnosis_text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.diagnosis_text.setVisible(False) - daily_card = QFrame() - self.daily_card = daily_card - daily_card.setObjectName("SubtleCard") - daily_layout = QVBoxLayout(daily_card) - daily_layout.setContentsMargins(16, 14, 16, 14) - daily_layout.setSpacing(9) - daily_layout.addWidget(section_title("近 30 日日常记录")) - self.daily_text = QLabel("选择患者后加载血糖血压、饮食、运动与跟踪备注。") - self.daily_text.setWordWrap(True) - self.daily_text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - daily_layout.addWidget(self.daily_text) + self.daily_panel = ReceptionDailyRecordsPanel() + self.daily_panel.range_requested.connect(self._request_daily_range) + self.daily_panel.refresh_requested.connect(self._refresh_daily_records) + + self.followup_card = QFrame() + self.followup_card.setObjectName("ReceptionFollowupCard") + self.followup_card.setAccessibleName("患者随访记录") + followup_layout = QVBoxLayout(self.followup_card) + followup_layout.setContentsMargins(16, 14, 16, 16) + followup_layout.setSpacing(10) + followup_title = QLabel("随访记录", self.followup_card) + followup_title.setObjectName("ReceptionFollowupTitle") + followup_layout.addWidget(followup_title) + followup_subtitle = QLabel( + "展示医生跟踪备注;日常指标请在“日常记录”页查看。", + self.followup_card, + ) + followup_subtitle.setObjectName("ReceptionFollowupSubtitle") + followup_subtitle.setWordWrap(True) + followup_layout.addWidget(followup_subtitle) + self.followup_text = QLabel("选择患者后加载跟踪备注。", self.followup_card) + self.followup_text.setObjectName("ReceptionFollowupText") + self.followup_text.setTextFormat(Qt.TextFormat.PlainText) + self.followup_text.setWordWrap(True) + self.followup_text.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + followup_layout.addWidget(self.followup_text) + followup_layout.addStretch(1) self.notes_group = QFrame() self.notes_group.setObjectName("SubtleCard") @@ -3538,7 +4088,12 @@ class ReceptionPage(QWidget): self.medication_scroll = add_scrolling_page(self.diagnosis_card) self.medication_scroll.setObjectName("ReceptionMedicationScroll") self.medication_scroll.setAccessibleName("用药记录与完整病例滚动区域") - self.followup_scroll = add_scrolling_page(self.daily_card) + self.daily_scroll = add_scrolling_page(self.daily_panel) + self.daily_scroll.setObjectName("ReceptionDailyRecordsScroll") + self.daily_scroll.setAccessibleName("患者日常记录滚动区域") + self.followup_scroll = add_scrolling_page(self.followup_card) + self.followup_scroll.setObjectName("ReceptionFollowupScroll") + self.followup_scroll.setAccessibleName("患者随访记录滚动区域") health_card = QFrame() health_card.setObjectName("ReceptionTabSurface") @@ -3558,6 +4113,10 @@ class ReceptionPage(QWidget): analysis_card = QFrame(content) analysis_card.setObjectName("ReceptionAiAnalysisCard") analysis_card.setMinimumWidth(0) + analysis_card.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Preferred, + ) self.ai_analysis_card = analysis_card analysis_layout = QVBoxLayout(analysis_card) analysis_layout.setContentsMargins(11, 12, 11, 14) @@ -3672,17 +4231,23 @@ class ReceptionPage(QWidget): _diagnosis_section, diagnosis_copy = add_analysis_section( "诊断建议", "diagnosis", emphasis=True ) - self.ai_summary_label = _SpacedTextLabel("—", analysis_sections) + self.ai_summary_label = _AiPreviewLabel( + "—", + analysis_sections, + maximum_lines=3, + ) self.ai_summary_label.setProperty("receptionAiValue", True) self.ai_summary_label.setMinimumWidth(0) diagnosis_copy.addWidget(self.ai_summary_label) _risk_section, risk_copy = add_analysis_section("风险评估", "risk") - self.ai_risk_chip_host = QWidget(analysis_sections) - self.ai_risk_chip_layout = QHBoxLayout(self.ai_risk_chip_host) - self.ai_risk_chip_layout.setContentsMargins(0, 0, 0, 0) - self.ai_risk_chip_layout.setSpacing(5) - self.ai_risk_chip_layout.addStretch(1) + self.ai_risk_chip_host = _AiFlowHost( + analysis_sections, + horizontal_spacing=5, + vertical_spacing=5, + ) + self.ai_risk_chip_host.setMinimumWidth(0) + self.ai_risk_chip_layout = self.ai_risk_chip_host.flow risk_copy.addWidget(self.ai_risk_chip_host) self.ai_risk_label = QLabel("", analysis_sections) self.ai_risk_label.setVisible(False) @@ -3690,38 +4255,28 @@ class ReceptionPage(QWidget): _treatment_section, treatment_copy = add_analysis_section( "治疗建议", "treatment" ) - self.ai_treatment_label = _SpacedTextLabel("—", analysis_sections) + self.ai_treatment_label = _AiPreviewLabel( + "—", + analysis_sections, + maximum_lines=2, + ) self.ai_treatment_label.setProperty("receptionAiValue", True) self.ai_treatment_label.setProperty("secondary", True) self.ai_treatment_label.setMinimumWidth(0) treatment_copy.addWidget(self.ai_treatment_label) - sections_layout.addStretch(1) analysis_sections.setMinimumWidth(0) analysis_sections.setSizePolicy( QSizePolicy.Policy.Ignored, - QSizePolicy.Policy.Minimum, + QSizePolicy.Policy.Preferred, ) - analysis_scroll = QScrollArea(self.ai_analysis_stack) - analysis_scroll.setWidgetResizable(True) - analysis_scroll.setFrameShape(QFrame.Shape.NoFrame) - analysis_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - analysis_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) - analysis_scroll.setSizeAdjustPolicy( - QScrollArea.SizeAdjustPolicy.AdjustIgnored - ) - analysis_scroll.setSizePolicy( - QSizePolicy.Policy.Expanding, - QSizePolicy.Policy.Expanding, - ) - analysis_scroll.setWidget(analysis_sections) - self.ai_analysis_content_page = analysis_scroll + self.ai_analysis_content_page = analysis_sections self.ai_analysis_stack.setSizePolicy( QSizePolicy.Policy.Expanding, - QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Preferred, ) - self.ai_analysis_stack.addWidget(analysis_scroll) + self.ai_analysis_stack.addWidget(analysis_sections) analysis_state = QFrame(self.ai_analysis_stack) analysis_state.setObjectName("ReceptionAiState") self.ai_analysis_state_page = analysis_state @@ -3747,7 +4302,7 @@ class ReceptionPage(QWidget): ) state_layout.addStretch(1) self.ai_analysis_stack.addWidget(analysis_state) - analysis_layout.addWidget(self.ai_analysis_stack, 1) + analysis_layout.addWidget(self.ai_analysis_stack) report_footer = QHBoxLayout() report_footer.setContentsMargins(0, 2, 0, 0) @@ -3790,6 +4345,10 @@ class ReceptionPage(QWidget): assistant_card = QFrame(content) assistant_card.setObjectName("ReceptionAiAssistantCard") assistant_card.setMinimumWidth(0) + assistant_card.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Preferred, + ) self.ai_assistant_card = assistant_card assistant_layout = QVBoxLayout(assistant_card) assistant_layout.setContentsMargins(12, 11, 12, 14) @@ -3883,6 +4442,7 @@ class ReceptionPage(QWidget): for scroll_area in ( getattr(self, "report_scroll", None), getattr(self, "medication_scroll", None), + getattr(self, "daily_scroll", None), getattr(self, "followup_scroll", None), getattr(self, "health_scroll", None), ): @@ -4053,8 +4613,12 @@ class ReceptionPage(QWidget): ) -> None: """Render a validated cached payload without mutating or truncating it.""" - diagnosis_advice = _summary_value(_analysis_value(payload, "diagnosis_advice")) - treatment_advice = _summary_value(_analysis_value(payload, "treatment_advice")) + diagnosis_advice = _ai_narrative_text( + _analysis_value(payload, "diagnosis_advice") + ) + treatment_advice = _ai_narrative_text( + _analysis_value(payload, "treatment_advice") + ) risks = _normalize_ai_risks(_analysis_value(payload, "risk_assessment")) self.ai_summary_label.setText(diagnosis_advice) self._render_ai_risk_chips(risks) @@ -4102,27 +4666,56 @@ class ReceptionPage(QWidget): def _render_ai_risk_chips(self, risks: Sequence[Mapping[str, Any]]) -> None: _clear_ai_layout(self.ai_risk_chip_layout) labels: list[str] = [] - for risk in risks[:4]: - label = _summary_value(risk.get("label"), "label") + normalized_risks: list[tuple[str, str]] = [] + for risk in risks: + label = _ai_narrative_text(risk.get("label")) if not label: continue level = str(risk.get("level") or "low").strip().lower() if level not in {"high", "medium", "low"}: level = "low" + normalized_risks.append((label, level)) + labels.append(label) + + for label, level in normalized_risks[:3]: chip = QLabel(label, self.ai_risk_chip_host) chip.setTextFormat(Qt.TextFormat.PlainText) + chip.setWordWrap(True) + chip.setMinimumWidth(0) + chip.setMaximumWidth(220) + chip.setSizePolicy( + QSizePolicy.Policy.Maximum, + QSizePolicy.Policy.Preferred, + ) chip.setProperty("receptionRiskChip", True) chip.setProperty("riskLevel", level) self.ai_risk_chip_layout.addWidget(chip) - labels.append(label) - if not labels: + if not normalized_risks: chip = QLabel("暂无明确风险", self.ai_risk_chip_host) chip.setTextFormat(Qt.TextFormat.PlainText) + chip.setSizePolicy( + QSizePolicy.Policy.Maximum, + QSizePolicy.Policy.Preferred, + ) chip.setProperty("receptionRiskChip", True) chip.setProperty("riskLevel", "low") self.ai_risk_chip_layout.addWidget(chip) labels.append(chip.text()) - self.ai_risk_chip_layout.addStretch(1) + elif len(normalized_risks) > 3: + overflow = QLabel( + f"+{len(normalized_risks) - 3} 项", + self.ai_risk_chip_host, + ) + overflow.setTextFormat(Qt.TextFormat.PlainText) + overflow.setSizePolicy( + QSizePolicy.Policy.Maximum, + QSizePolicy.Policy.Preferred, + ) + overflow.setProperty("receptionRiskOverflow", True) + overflow.setToolTip("其余风险请在完整 AI 报告中查看") + self.ai_risk_chip_layout.addWidget(overflow) + self.ai_risk_chip_layout.invalidate() + self.ai_risk_chip_host.syncFlowHeight() self.ai_risk_label.setText("、".join(labels)) def _ai_analysis_context_current( @@ -4708,8 +5301,12 @@ class ReceptionPage(QWidget): model, ) return - diagnosis_advice = _summary_value(_analysis_value(payload, "diagnosis_advice")) - treatment_advice = _summary_value(_analysis_value(payload, "treatment_advice")) + diagnosis_advice = _ai_narrative_text( + _analysis_value(payload, "diagnosis_advice") + ) + treatment_advice = _ai_narrative_text( + _analysis_value(payload, "treatment_advice") + ) if not diagnosis_advice or not treatment_advice: self._ai_analysis_error( ValueError("服务端未返回完整的诊断建议与治疗建议。"), @@ -4934,6 +5531,7 @@ class ReceptionPage(QWidget): "page_no": 1, "page_size": page_size, "patient_name": self.search_edit.text().strip(), + "include_status_counts": 1, } query_key = self._query_key(query) if query_key != self._queue_query_key: @@ -5013,15 +5611,36 @@ class ReceptionPage(QWidget): def _sync_queue_rows(self, records: list[Any], *, append: bool) -> None: render_signature = tuple(_queue_row_signature(record) for record in records) existing = self.queue_list.count() - if existing == len(records) and render_signature == self._queue_render_signature: + existing_ids = tuple( + _record_id(self.queue_list.item(index).data(Qt.ItemDataRole.UserRole)) + for index in range(existing) + ) + record_ids = tuple(_record_id(record) for record in records) + stable_order = ( + existing == len(records) + and len(self._queue_render_signature) == existing + and all(record_id is not None for record_id in record_ids) + and existing_ids == record_ids + ) + if stable_order and render_signature == self._queue_render_signature: for index, record in enumerate(records): self.queue_list.item(index).setData(Qt.ItemDataRole.UserRole, record) self._queue_render_signature = render_signature return + if stable_order: + for index, record in enumerate(records): + item = self.queue_list.item(index) + item.setData(Qt.ItemDataRole.UserRole, record) + if render_signature[index] != self._queue_render_signature[index]: + self.queue_list.setItemWidget(item, QueueRow(record)) + self._queue_render_signature = render_signature + return if ( append and existing and existing < len(records) + and all(record_id is not None for record_id in record_ids[:existing]) + and existing_ids == record_ids[:existing] and render_signature[:existing] == self._queue_render_signature ): for index in range(existing): @@ -5092,7 +5711,7 @@ class ReceptionPage(QWidget): run_async( lambda frozen_query=frozen_query: invoke( self.repository, - "reception_queue", + "list_appointments", **frozen_query, ), on_success=lambda result: self._apply_queue( @@ -5296,7 +5915,7 @@ class ReceptionPage(QWidget): def _fetch_detail_bundle(self, record: Any, appointment_id: int) -> dict[str, Any]: detail = invoke( self.repository, - "reception_detail", + "get_reception", appointment_id=appointment_id, id=appointment_id, ) @@ -5316,7 +5935,10 @@ class ReceptionPage(QWidget): notes = get_value(detail, "doctor_notes", None) or get_value(detail, "notes", None) or [] notes = list(notes) if isinstance(notes, (list, tuple)) else [notes] daily: Any = {} - tracking_notes: list[Any] = [] + embedded_tracking = get_value(detail, "tracking_notes", None) or [] + tracking_notes = list(embedded_tracking) if isinstance( + embedded_tracking, (list, tuple) + ) else ([embedded_tracking] if embedded_tracking else []) warnings: list[str] = [] if diagnosis_id is not None and callable( getattr(self.repository, "get_doctor_notes", None) @@ -5340,7 +5962,7 @@ class ReceptionPage(QWidget): self.repository, "get_tracking_window", diagnosis_id=diagnosis_id, - start_date=(end_date - timedelta(days=29)).isoformat(), + start_date=(end_date - timedelta(days=6)).isoformat(), end_date=end_date.isoformat(), ) except Exception as error: @@ -5366,6 +5988,158 @@ class ReceptionPage(QWidget): "warnings": warnings, } + def _refresh_daily_records(self) -> None: + start_date, end_date = self.daily_panel.current_range() + self._request_daily_range(start_date, end_date) + + def _request_daily_range(self, start_date: str, end_date: str) -> None: + detail = self._selected_detail or {} + diagnosis = get_value(detail, "diagnosis", None) or {} + diagnosis_id = _as_int( + first_value( + diagnosis, + "id", + "diagnosis_id", + default=first_value(self._selected_record, "diagnosis_id", default=None), + ) + ) + appointment_id = self._selected_appointment_id + if appointment_id is None or diagnosis_id is None: + self.daily_panel.set_error("当前患者缺少诊单编号,无法读取日常记录。") + return + start = str(start_date or "").strip() + end = str(end_date or "").strip() + if not start or not end or start > end: + self.daily_panel.set_error("请选择有效的日常记录日期范围。") + return + + self._daily_generation += 1 + generation = self._daily_generation + self._daily_loading = True + self._daily_diagnosis_id = diagnosis_id + request_key = (generation, appointment_id, diagnosis_id) + self._daily_requests.add(request_key) + self.daily_panel.set_loading(True) + fallback_notes = list(self._daily_tracking_fallback) + run_async( + lambda: self._fetch_daily_bundle( + diagnosis_id, + start, + end, + fallback_notes=fallback_notes, + ), + on_success=lambda bundle: self._apply_daily_bundle( + bundle, + generation, + appointment_id, + diagnosis_id, + ), + on_error=lambda error: self._daily_error( + error, + generation, + appointment_id, + diagnosis_id, + ), + on_finished=lambda: self._daily_finished( + generation, + appointment_id, + diagnosis_id, + ), + ) + + def _fetch_daily_bundle( + self, + diagnosis_id: int, + start_date: str, + end_date: str, + *, + fallback_notes: list[Any], + ) -> dict[str, Any]: + window_method = getattr(self.repository, "get_tracking_window", None) + if not callable(window_method): + raise RuntimeError("当前后端客户端未提供日常记录接口。") + window = invoke( + self.repository, + "get_tracking_window", + diagnosis_id=diagnosis_id, + start_date=start_date, + end_date=end_date, + ) + tracking_notes = list(fallback_notes) + warnings: list[str] = [] + if callable(getattr(self.repository, "list_tracking_notes", None)): + try: + tracking_notes = page_items( + invoke( + self.repository, + "list_tracking_notes", + diagnosis_id=diagnosis_id, + ) + ) + except Exception as error: + warnings.append(f"跟踪备注:{friendly_error(error)}") + return { + "daily": window, + "tracking_notes": tracking_notes, + "warnings": warnings, + "start_date": start_date, + "end_date": end_date, + } + + def _apply_daily_bundle( + self, + bundle: Any, + generation: int, + appointment_id: int, + diagnosis_id: int, + ) -> None: + if ( + generation != self._daily_generation + or not _same_id(appointment_id, self._selected_appointment_id) + or diagnosis_id != self._daily_diagnosis_id + ): + return + tracking_notes = list(get_value(bundle, "tracking_notes", None) or []) + self._daily_tracking_fallback = tracking_notes + self._render_daily(get_value(bundle, "daily", None) or {}, tracking_notes) + warnings = list(get_value(bundle, "warnings", None) or []) + if warnings: + self.daily_panel.state.show_message( + ";".join(str(item) for item in warnings), + "warning", + ) + + def _daily_error( + self, + error: Exception, + generation: int, + appointment_id: int, + diagnosis_id: int, + ) -> None: + if ( + generation == self._daily_generation + and _same_id(appointment_id, self._selected_appointment_id) + and diagnosis_id == self._daily_diagnosis_id + ): + self.daily_panel.set_error( + f"日常记录加载失败:{friendly_error(error)}(已保留上次数据)" + ) + + def _daily_finished( + self, + generation: int, + appointment_id: int, + diagnosis_id: int, + ) -> None: + self._daily_requests.discard((generation, appointment_id, diagnosis_id)) + if ( + generation == self._daily_generation + and _same_id(appointment_id, self._selected_appointment_id) + and diagnosis_id == self._daily_diagnosis_id + ): + self._daily_loading = False + self.daily_panel.set_loading(False) + def _apply_detail( self, bundle: Any, generation: int, appointment_id: int | None = None ) -> None: @@ -5405,16 +6179,6 @@ class ReceptionPage(QWidget): if notes is None: notes = get_value(detail, "doctor_notes", None) or get_value(detail, "notes", None) self._render_notes(list(notes or [])) - self._render_daily( - get_value(bundle, "daily", None) or {}, - list(get_value(bundle, "tracking_notes", None) or []), - ) - warnings = list(get_value(bundle, "warnings", None) or []) - if warnings: - self.detail_banner.show_message(";".join(str(item) for item in warnings), "warning") - else: - self.detail_banner.clear() - self._update_action_state(appointment, diagnosis) diagnosis_id = _as_int( first_value( diagnosis, @@ -5423,25 +6187,39 @@ class ReceptionPage(QWidget): default=first_value(self._selected_record, "diagnosis_id", default=None), ) ) + self._daily_generation += 1 + self._daily_loading = False + self._daily_diagnosis_id = diagnosis_id + tracking_notes = list(get_value(bundle, "tracking_notes", None) or []) + self._daily_tracking_fallback = tracking_notes + self.daily_panel.set_patient_age( + first_value( + diagnosis, + "age", + default=first_value(patient, "age", default=first_value(appointment, "age")), + ) + ) + self._render_daily( + get_value(bundle, "daily", None) or {}, + tracking_notes, + ) + warnings = list(get_value(bundle, "warnings", None) or []) + if warnings: + self.detail_banner.show_message(";".join(str(item) for item in warnings), "warning") + else: + self.detail_banner.clear() + self._update_action_state(appointment, diagnosis) patient_id = _as_int( first_value( - appointment, + diagnosis, "patient_id", + "source_patient_id", default=first_value( - diagnosis, + patient, "patient_id", "source_patient_id", - default=first_value( - patient, - "source_patient_id", - "patient_id", - "id", - default=first_value( - self._selected_record, - "patient_id", - default=None, - ), - ), + "id", + default=None, ), ) ) @@ -6200,48 +6978,26 @@ class ReceptionPage(QWidget): return _mask_phone(masked or raw) def _render_daily(self, daily: Any, tracking_notes: list[Any]) -> None: - lines: list[str] = [] - groups = ( - ( - "血糖血压", - first_value(daily, "blood_records", "blood", "blood_pressure_records", default=[]), - ), - ("饮食", first_value(daily, "diet_records", "diet", default=[])), - ("运动", first_value(daily, "exercise_records", "exercise", default=[])), - ) - for title, raw_rows in groups: - rows = _sequence(raw_rows) - for row in rows[:8]: - record_date = display_text(first_value(row, "record_date", "date", "create_time")) - if title == "血糖血压": - systolic = first_value(row, "systolic", "high_pressure", default=None) - diastolic = first_value(row, "diastolic", "low_pressure", default=None) - glucose = first_value( - row, - "fasting_glucose", - "blood_sugar", - "postprandial_glucose", - default=None, - ) - values = [] - if systolic not in (None, "") or diastolic not in (None, ""): - values.append(f"血压 {display_text(systolic)}/{display_text(diastolic)}") - if glucose not in (None, ""): - values.append(f"血糖 {display_text(glucose)}") - content = ",".join(values) or display_text( - first_value(row, "content", "remark") - ) - else: - content = display_text( - first_value(row, "content", "description", "remark", "record_content") - ) - lines.append(f"{title} · {record_date}:{content}") - for row in tracking_notes[:8]: - lines.append( - f"跟踪备注 · {display_text(first_value(row, 'note_date', 'create_time'))}:" - f"{display_text(first_value(row, 'tracking_content', 'content', 'remark'))}" + start_date = str(first_value(daily, "start_date", default="") or "")[:10] + end_date = str(first_value(daily, "end_date", default="") or "")[:10] + if start_date and end_date: + self.daily_panel.set_range(start_date, end_date) + self.daily_panel.set_data(daily, tracking_notes) + + followup_lines: list[str] = [] + for row in tracking_notes[:60]: + note_date = display_text( + first_value(row, "note_date", "record_date", "create_time") ) - self.daily_text.setText("\n".join(lines) if lines else "近 30 日暂无日常记录。") + content = display_text( + first_value(row, "content", "tracking_content", "remark") + ) + if content == "—": + continue + followup_lines.append(f"{note_date} · {content}") + self.followup_text.setText( + "\n\n".join(followup_lines) if followup_lines else "暂无跟踪备注。" + ) def _render_notes(self, notes: list[Any]) -> None: self._attachment_render_generation += 1 @@ -6537,7 +7293,16 @@ class ReceptionPage(QWidget): self.prescription_hint.setProperty("casePrescriptionState", "unknown") self.prescription_hint.style().unpolish(self.prescription_hint) self.prescription_hint.style().polish(self.prescription_hint) - self.daily_text.setText("正在加载日常记录…" if seed else "选择患者后加载日常记录。") + self._daily_generation += 1 + self._daily_loading = False + self._daily_diagnosis_id = None + self._daily_tracking_fallback = [] + self.daily_panel.clear_with_message( + "正在读取患者日常记录…" if seed else "选择患者后加载最近 7 天日常记录。" + ) + self.followup_text.setText( + "正在加载跟踪备注…" if seed else "选择患者后加载跟踪备注。" + ) self._ai_analysis_generation += 1 self._ai_analysis_loading = False self._ai_analysis_diagnosis_id = None @@ -6585,9 +7350,15 @@ class ReceptionPage(QWidget): ) patient_id = _as_int( first_value( - appointment, + diagnosis, "patient_id", - default=first_value(diagnosis, "patient_id", "source_patient_id", default=None), + "source_patient_id", + default=first_value( + appointment, + "source_patient_id", + "stable_patient_id", + default=None, + ), ) ) status = _as_int(first_value(appointment, "status", default=None)) @@ -6607,6 +7378,7 @@ class ReceptionPage(QWidget): and not self._detail_loading ) self.ai_button.setEnabled(report_enabled) + self.ai_consult_button.setEnabled(assistant_enabled) self.ai_report_link.setEnabled(report_enabled) self.ai_question_edit.setEnabled(assistant_enabled) self.ai_send_button.setEnabled(assistant_enabled) @@ -6648,7 +7420,12 @@ class ReceptionPage(QWidget): first_value(self._selected_record, "diagnosis_id", default=None) ) patient_id = _as_int( - first_value(self._selected_record, "patient_id", default=None) + first_value( + self._selected_record, + "source_patient_id", + "stable_patient_id", + default=None, + ) ) self._ai_analysis_diagnosis_id = diagnosis_id self._ai_analysis_patient_id = patient_id @@ -6710,6 +7487,7 @@ class ReceptionPage(QWidget): get_value(self._selected_detail, "appointment", None) or self._selected_record or {} ) diagnosis = get_value(self._selected_detail, "diagnosis", None) or {} + patient = get_value(self._selected_detail, "patient", None) or {} diagnosis_id = _as_int( first_value( diagnosis, @@ -6720,13 +7498,20 @@ class ReceptionPage(QWidget): ) patient_id = _as_int( first_value( - appointment, + diagnosis, "patient_id", + "source_patient_id", default=first_value( - diagnosis, + patient, "patient_id", "source_patient_id", - default=first_value(self._selected_record, "patient_id", default=None), + "id", + default=first_value( + appointment, + "source_patient_id", + "stable_patient_id", + default=None, + ), ), ) ) @@ -7040,6 +7825,28 @@ class ReceptionPage(QWidget): task=diagnosis_ai_task(prompt), ) + def _open_ai_consult(self) -> None: + if not self._can_ai_assistant: + show_toast(self, "当前账号没有使用 AI 问诊助手的权限。", "danger") + return + if self._detail_loading: + show_toast(self, "患者病历仍在加载,请稍候。", "warning") + return + context = self._selection_context() + if context is None or context[2] is None: + show_toast(self, "当前患者缺少诊单编号。", "danger") + return + seed = self._selected_detail or self._selected_record or {} + present_ai_consult( + self.repository, + self.permissions, + self, + diagnosis_id=int(context[2]), + patient_id=int(context[3] or 0), + seed=seed, + source_title="接诊台", + ) + def _open_ai_report( self, ) -> None: @@ -7208,4 +8015,4 @@ class ReceptionPage(QWidget): super().hideEvent(event) -__all__ = ["ReceptionPage"] +__all__ = ["ReceptionDailyRecordsPanel", "ReceptionPage"] diff --git a/app/src/doctor_workstation/ui/shell.py b/app/src/doctor_workstation/ui/shell.py index 1feff9ed7..17916471d 100644 --- a/app/src/doctor_workstation/ui/shell.py +++ b/app/src/doctor_workstation/ui/shell.py @@ -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) diff --git a/app/src/doctor_workstation/ui/widgets.py b/app/src/doctor_workstation/ui/widgets.py index 74ebb61bc..de6f5d1d9 100644 --- a/app/src/doctor_workstation/ui/widgets.py +++ b/app/src/doctor_workstation/ui/widgets.py @@ -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", diff --git a/app/tests/test_ai_consult_ui.py b/app/tests/test_ai_consult_ui.py new file mode 100644 index 000000000..ed8703150 --- /dev/null +++ b/app/tests/test_ai_consult_ui.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +import os +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import QApplication, QLabel, QTextBrowser + +from doctor_workstation.core import PermissionSet +from doctor_workstation.services import DemoDoctorRepository +from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module +from doctor_workstation.ui.dialogs.ai_consult import ( + AiConsultDialog, + can_open_ai_consult, + present_ai_consult, + render_chat_payload, +) +from doctor_workstation.ui.pages.appointments import AppointmentsPage +from doctor_workstation.ui.pages.consultations import ConsultationsPage +from doctor_workstation.ui.pages.patients import PatientListWorkspace +from doctor_workstation.ui.pages.reception import ReceptionPage + + +@pytest.fixture(scope="module") +def application() -> QApplication: + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None: + def run_immediately( + function: Any, + *args: Any, + on_success: Any = None, + on_error: Any = None, + on_finished: Any = None, + **kwargs: Any, + ) -> object: + try: + result = function(*args, **kwargs) + except Exception as error: + if on_error: + on_error(error) + else: + if on_success: + on_success(result) + finally: + if on_finished: + on_finished() + return object() + + monkeypatch.setattr(ai_consult_module, "run_async", run_immediately) + + +def test_ai_consult_dialog_matches_workspace_chrome( + application: QApplication, + immediate_async: None, +) -> None: + repository = DemoDoctorRepository() + dialog = AiConsultDialog(repository, PermissionSet(["tcm.diagnosis/aiAssistant"])) + dialog.open_for( + diagnosis_id=501, + patient_id=301, + seed={"patient_name": "杨永", "age": 52, "clinical_diagnosis": "2型糖尿病"}, + source_title="问诊列表", + ) + dialog.show() + application.processEvents() + + labels = [widget.text() for widget in dialog.findChildren(QLabel) if widget.text()] + assert "问诊详情" in labels + assert "AI 助手" in labels + assert "智能分析" in labels + assert "快捷工具" in labels + assert "对话建议" in labels + assert dialog.tabs.tabText(0) == "问诊对话" + assert dialog.send_button.objectName() == "AiConsultSend" + dialog.close() + + +def test_present_ai_consult_requires_diagnosis_id( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + opened: list[int] = [] + monkeypatch.setattr(ai_consult_module.AiConsultDialog, "exec", lambda self: opened.append(self.diagnosis_id)) + present_ai_consult( + DemoDoctorRepository(), + PermissionSet(["tcm.diagnosis/aiAssistant"]), + None, + diagnosis_id=0, + ) + assert opened == [] + present_ai_consult( + DemoDoctorRepository(), + PermissionSet(["tcm.diagnosis/aiAssistant"]), + None, + diagnosis_id=501, + seed={"patient_name": "杨永"}, + ) + assert opened == [501] + + +def test_four_entry_points_expose_ai_consult_action(application: QApplication) -> None: + repository = DemoDoctorRepository() + allowed = PermissionSet(["*", "tcm.diagnosis/aiAssistant"]) + assert can_open_ai_consult(allowed) + + patients = PatientListWorkspace(repository, allowed) + patients.show() + application.processEvents() + assert patients.ai_consult_button.text() == "AI 分析" + assert not patients.ai_consult_button.isHidden() + + reception = ReceptionPage(repository, allowed) + reception.show() + application.processEvents() + menu_titles = [action.text() for action in reception.more_button.menu().actions()] + assert "AI 分析" in menu_titles + assert reception.ai_consult_button.text() == "AI 分析" + + appointments = AppointmentsPage(repository, permissions=allowed) + appointments.show() + application.processEvents() + assert appointments.toolbar_ai_consult_button.text() == "AI 分析" + assert not appointments.toolbar_ai_consult_button.isHidden() + + consultations = ConsultationsPage(repository, permissions=allowed) + assert consultations.table_host.action_policy.get("ai_consult") is True + patients.close() + reception.close() + appointments.close() + consultations.close() + + +def test_ai_consult_sidebar_loads_patient_facts_and_reports( + application: QApplication, + immediate_async: None, +) -> None: + dialog = AiConsultDialog( + DemoDoctorRepository(), + PermissionSet(["tcm.diagnosis/aiAssistant"]), + ) + dialog.open_for(diagnosis_id=501, patient_id=301, seed={"patient_name": "林晓岚"}) + dialog.show() + application.processEvents() + + values = { + widget.text() + for widget in dialog.findChildren(QLabel) + if widget.objectName() == "AiConsultKeyValue" + } + assert "22.1" in values + assert any("病程" in text or "3" in text for text in values) + titles = { + widget.text() + for widget in dialog.findChildren(QLabel) + if widget.objectName() == "AiConsultRecordTitle" + } + assert "血糖控制评估" in titles + assert "并发症风险评估" in titles + bodies = [ + widget.toPlainText() + for widget in dialog.findChildren(QTextBrowser) + if widget.objectName() == "AiConsultBubbleText" + ] + assert any("病情与证候分析" in text for text in bodies) + assert any("###" not in text for text in bodies if "病情与证候分析" in text) + dialog.close() + + +def test_ai_consult_sidebar_survives_chat_archive_errors( + application: QApplication, + immediate_async: None, +) -> None: + class BrokenChatRepository(DemoDoctorRepository): + def list_im_chat_messages(self, diagnosis_id: int, *, only_archived: bool = True): + raise RuntimeError("archive unavailable") + + dialog = AiConsultDialog( + BrokenChatRepository(), + PermissionSet(["tcm.diagnosis/aiAssistant"]), + ) + dialog.open_for(diagnosis_id=501, patient_id=301) + dialog.show() + application.processEvents() + values = { + widget.text() + for widget in dialog.findChildren(QLabel) + if widget.objectName() == "AiConsultKeyValue" + } + titles = { + widget.text() + for widget in dialog.findChildren(QLabel) + if widget.objectName() == "AiConsultRecordTitle" + } + assert "22.1" in values + assert "血糖控制评估" in titles + dialog.close() + + +def test_chat_payload_parses_markdown_html_and_json(application: QApplication) -> None: + browser = QTextBrowser() + render_chat_payload(browser, "### 病情摘要\n\n**核心病机**\n\n- 口干") + assert "病情摘要" in browser.toPlainText() + assert "核心病机" in browser.toPlainText() + assert "###" not in browser.toPlainText() + assert "空腹血糖 6.8

") + assert "空腹血糖" in browser.toPlainText() + assert "6.8" in browser.toPlainText() + + render_chat_payload(browser, '{"diagnosis":"肝郁脾虚证","risk":["血糖波动"]}') + assert "肝郁脾虚证" in browser.toPlainText() + browser.deleteLater() + + +def test_stream_chunks_update_one_ai_bubble_before_done_and_preserve_order( + application: QApplication, +) -> None: + dialog = AiConsultDialog( + DemoDoctorRepository(), + PermissionSet(["tcm.diagnosis/aiAssistant"]), + ) + dialog.show() + dialog._stream_bubble = dialog._append_bubble("ai", "") + bubble = dialog._stream_bubble + generation = dialog._generation + stream_generation = dialog._stream_generation + + dialog._stream_event( + generation, + stream_generation, + {"event": "delta", "text": "第一段"}, + ) + dialog._flush_timer.stop() + dialog._flush_stream_chunks() + application.processEvents() + assert bubble is not None and bubble.body is not None + assert bubble.body.toPlainText() == "第一段" + ai_bubble_count = len( + [frame for frame in dialog.findChildren(ai_consult_module.QFrame) if frame.objectName() == "AiConsultBubbleAi"] + ) + + dialog._stream_event( + generation, + stream_generation, + {"event": "delta", "text": "第二段"}, + ) + dialog._stream_event( + generation, + stream_generation, + {"event": "done", "model_label": "千问"}, + ) + application.processEvents() + assert bubble.body.toPlainText() == "第一段第二段" + assert len( + [frame for frame in dialog.findChildren(ai_consult_module.QFrame) if frame.objectName() == "AiConsultBubbleAi"] + ) == ai_bubble_count + dialog.close() + + +def test_stream_error_and_cancelled_late_chunk_reuse_or_leave_current_bubble( + application: QApplication, +) -> None: + dialog = AiConsultDialog( + DemoDoctorRepository(), + PermissionSet(["tcm.diagnosis/aiAssistant"]), + ) + dialog.show() + dialog._stream_bubble = dialog._append_bubble("ai", "") + bubble = dialog._stream_bubble + generation = dialog._generation + stream_generation = dialog._stream_generation + dialog._stream_event( + generation, + stream_generation, + {"event": "delta", "text": "已生成"}, + ) + dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙")) + application.processEvents() + assert bubble is not None and bubble.body is not None + assert "已生成" in bubble.body.toPlainText() + assert "模型繁忙" in bubble.body.toPlainText() + + before_cancel = bubble.body.toPlainText() + dialog.close() + application.processEvents() + dialog._stream_event( + generation, + stream_generation, + {"event": "delta", "text": "迟到内容"}, + ) + application.processEvents() + assert bubble.body.toPlainText() == before_cancel + + +def test_chat_scroll_follows_bottom_but_respects_user_scroll_and_send_restores_it( + application: QApplication, +) -> None: + dialog = AiConsultDialog( + DemoDoctorRepository(), + PermissionSet(["tcm.diagnosis/aiAssistant"]), + ) + dialog.diagnosis_id = 501 + dialog.show() + for index in range(28): + dialog._append_bubble("ai", f"历史消息 {index}:" + "辨证内容" * 16) + application.processEvents() + bar = dialog.chat_scroll.verticalScrollBar() + bar.setValue(bar.maximum()) + application.processEvents() + assert dialog._follow_chat + + bar.setValue(max(0, bar.maximum() // 3)) + application.processEvents() + reading_position = bar.value() + assert not dialog._follow_chat + dialog._append_bubble("ai", "新的流式内容" * 20) + application.processEvents() + assert bar.value() == reading_position + + dialog._ask("请继续分析") + application.processEvents() + assert dialog._follow_chat + assert bar.value() == bar.maximum() + dialog.close() diff --git a/app/tests/test_ai_consult_workspace_ui.py b/app/tests/test_ai_consult_workspace_ui.py new file mode 100644 index 000000000..bc3a8c4a9 --- /dev/null +++ b/app/tests/test_ai_consult_workspace_ui.py @@ -0,0 +1,866 @@ +from __future__ import annotations + +import os +from collections.abc import Callable +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtWidgets import ( + QApplication, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QTextBrowser, + QTextEdit, + QWidget, +) + +from doctor_workstation.core import PermissionSet +from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module +from doctor_workstation.ui.dialogs import prescription as prescription_module +from doctor_workstation.ui.dialogs.ai_consult import AiConsultDialog + + +@pytest.fixture(scope="module") +def application() -> QApplication: + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None: + def run_immediately( + function: Callable[..., Any], + *args: Any, + on_success: Callable[[Any], Any] | None = None, + on_error: Callable[[Exception], Any] | None = None, + on_finished: Callable[[], Any] | None = None, + **_kwargs: Any, + ) -> object: + try: + result = function(*args) + except Exception as error: + if on_error: + on_error(error) + else: + if on_success: + on_success(result) + finally: + if on_finished: + on_finished() + return object() + + monkeypatch.setattr(ai_consult_module, "run_async", run_immediately) + + +class DeferredAsync: + def __init__(self) -> None: + self.pending: list[dict[str, Any]] = [] + + def __call__( + self, + function: Callable[..., Any], + *args: Any, + on_success: Callable[[Any], Any] | None = None, + on_error: Callable[[Exception], Any] | None = None, + on_finished: Callable[[], Any] | None = None, + **_kwargs: Any, + ) -> object: + self.pending.append( + { + "function": function, + "args": args, + "on_success": on_success, + "on_error": on_error, + "on_finished": on_finished, + } + ) + return object() + + def complete(self, index: int) -> None: + pending = self.pending[index] + try: + result = pending["function"](*pending["args"]) + except Exception as error: + if pending["on_error"]: + pending["on_error"](error) + else: + if pending["on_success"]: + pending["on_success"](result) + finally: + if pending["on_finished"]: + pending["on_finished"]() + + +def _detail(diagnosis_id: int, marker: str) -> dict[str, Any]: + diagnosis = { + "id": diagnosis_id, + "patient_id": diagnosis_id + 1000, + "patient_name": f"{marker}患者", + "phone": "13800138000", + "id_card": "110105199203071234", + "gender": 0, + "age": 34, + "region": f"{marker}杭州", + "address": f"{marker}健康路 8 号", + "height": 162, + "weight": 54.5, + "bmi": 20.8, + "systolic_pressure": 146, + "diastolic_pressure": 92, + "fasting_blood_sugar": 8.2, + "chief_complaint": f"{marker}主诉口渴乏力", + "present_illness": f"{marker}现病史半年血糖波动", + "past_history": f"{marker}既往高血压五年", + "allergy_history": f"{marker}青霉素过敏", + "family_history": f"{marker}父亲糖尿病", + "clinical_diagnosis": f"{marker}气阴两虚", + "diabetes_discovery_year": 6, + "current_medications": [f"{marker}二甲双胍", "阿卡波糖"], + "smoking": "不吸烟", + "sleep_condition": [f"{marker}易醒", "多梦"], + "local_hospital_diagnosis": [f"{marker}2 型糖尿病", "高血压"], + "diet_condition": [f"{marker}偏甜", "夜宵"], + "body_feeling": [f"{marker}乏力", "四肢沉重"], + "tongue": f"{marker}舌淡红", + "tongue_coating": f"{marker}苔薄白", + "pulse": f"{marker}脉细", + "remark": f"{marker}继续监测", + "latest_prescription_order": { + "id": f"{marker}-RX-09", + "status_text": "待配药", + }, + } + for index in range(12): + diagnosis[f"custom_field_{index}"] = f"{marker}扩展病历字段 {index}" + return { + "diagnosis": diagnosis, + "patient": { + "id": diagnosis_id + 1000, + "patient_name": f"{marker}患者", + "phone": "13800138000", + "id_card": "110105199203071234", + "gender": 0, + "age": 34, + "region": f"{marker}杭州", + "address": f"{marker}健康路 8 号", + }, + "appointment": {"doctor_name": f"{marker}陈医生"}, + } + + +class WorkspaceRepository: + def __init__(self, *, include_foreign_rows: bool = True) -> None: + self.details = {501: _detail(501, "甲"), 502: _detail(502, "乙")} + self.include_foreign_rows = include_foreign_rows + self.failures: set[tuple[str, int]] = set() + self.calls: list[tuple[str, int]] = [] + self.prescription_detail_calls: list[int] = [] + self.prescription_overrides: dict[int, dict[str, Any]] = {} + self.report_payload: Any = [] + + def _check(self, name: str, diagnosis_id: int) -> None: + self.calls.append((name, diagnosis_id)) + if (name, diagnosis_id) in self.failures: + raise RuntimeError(f"{name} 暂时不可用") + + def get_diagnosis_detail( + self, + diagnosis_id: int, + *, + readonly: bool = False, + ) -> dict[str, Any]: + del readonly + self._check("get_diagnosis_detail", diagnosis_id) + return self.details[diagnosis_id] + + def list_im_chat_messages( + self, + diagnosis_id: int, + *, + only_archived: bool = True, + ) -> list[dict[str, Any]]: + del only_archived + self._check("list_im_chat_messages", diagnosis_id) + return [] + + def list_patient_ai_reports(self, patient_id: int) -> Any: + self.calls.append(("list_patient_ai_reports", patient_id)) + return self.report_payload + + def get_doctor_notes(self, diagnosis_id: int) -> list[dict[str, Any]]: + self._check("get_doctor_notes", diagnosis_id) + marker = "甲" if diagnosis_id == 501 else "乙" + rows = [ + { + "id": diagnosis_id * 10 + 1, + "diagnosis_id": diagnosis_id, + "create_time": "2026-08-18 09:20", + "content": f"{marker}医生检查记录", + "tongue_images": [ + { + "name": f"{marker}舌苔照片.jpg", + "url": f"https://media.example.invalid/{marker}/tongue.jpg", + } + ], + "report_files": [ + { + "name": f"{marker}血糖报告.pdf", + "url": f"https://media.example.invalid/{marker}/report.pdf", + }, + { + "name": f"{marker}本地危险附件.pdf", + "url": "file:///C:/private/unsafe.pdf", + }, + ], + } + ] + if self.include_foreign_rows: + rows.append( + { + "id": 99901, + "diagnosis_id": 999, + "content": "错误诊单附件哨兵", + "tongue_images": [ + { + "name": "错误诊单舌苔.jpg", + "url": "https://media.example.invalid/wrong.jpg", + } + ], + } + ) + return rows + + def get_tracking_window(self, diagnosis_id: int) -> dict[str, Any]: + self._check("get_tracking_window", diagnosis_id) + marker = "甲" if diagnosis_id == 501 else "乙" + blood_records = [ + { + "id": diagnosis_id * 10 + 2, + "diagnosis_id": diagnosis_id, + "record_date": "2026-08-18", + "fasting_blood_sugar": f"{marker}8.2", + "postprandial_blood_sugar": f"{marker}12.4", + "systolic_pressure": f"{marker}146", + "diastolic_pressure": f"{marker}92", + } + ] + if self.include_foreign_rows: + blood_records.append( + { + "id": 99902, + "diagnosis_id": 999, + "record_date": "2026-08-18", + "fasting_blood_sugar": "错误诊单血糖 19.9", + } + ) + return { + "diagnosis_id": diagnosis_id, + "blood_records": blood_records, + "diet_records": [ + { + "id": diagnosis_id * 10 + 3, + "diagnosis_id": diagnosis_id, + "record_date": "2026-08-18", + "breakfast_foods": f"{marker}燕麦鸡蛋", + "lunch_foods": f"{marker}杂粮饭", + } + ], + "exercise_records": [ + { + "id": diagnosis_id * 10 + 4, + "diagnosis_id": diagnosis_id, + "record_date": "2026-08-17", + "exercise_type": f"{marker}散步", + "duration": 35, + } + ], + } + + def list_prescriptions_by_diagnosis( + self, + diagnosis_id: int, + ) -> list[dict[str, Any]]: + self._check("list_prescriptions_by_diagnosis", diagnosis_id) + marker = "甲" if diagnosis_id == 501 else "乙" + return [ + { + "id": diagnosis_id * 10 + index, + "diagnosis_id": diagnosis_id, + "sn": f"{marker}-RX-{index}", + "prescription_date": f"2026-08-{10 + index}", + "prescription_summary": f"{marker}方剂 {index}", + "doctor_name": f"{marker}陈医生", + "status_text": "已审核", + "herbs": [{"name": f"{marker}黄芪", "dosage": index * 5, "unit": "g"}], + } + for index in range(1, 4) + ] + + def get_prescription(self, prescription_id: int) -> dict[str, Any]: + self.prescription_detail_calls.append(prescription_id) + if prescription_id in self.prescription_overrides: + return self.prescription_overrides[prescription_id] + diagnosis_id = prescription_id // 10 + return { + "id": prescription_id, + "diagnosis_id": diagnosis_id, + "sn": f"FULL-{prescription_id}", + "clinical_diagnosis": "气阴两虚", + "herbs": [{"name": "黄芪", "dosage": 15, "unit": "g"}], + } + + +def _permissions() -> PermissionSet: + return PermissionSet(["tcm.diagnosis/aiAssistant", "cf.prescription/read"]) + + +def _pane_text(widget: QWidget) -> str: + parts = [child.text() for child in widget.findChildren(QLabel)] + parts.extend(child.text() for child in widget.findChildren(QPushButton)) + parts.extend(child.text() for child in widget.findChildren(QLineEdit)) + parts.extend(child.toPlainText() for child in widget.findChildren(QTextBrowser)) + parts.extend(child.toPlainText() for child in widget.findChildren(QTextEdit)) + return "\n".join(part for part in parts if part) + + +def _open_dialog( + application: QApplication, + repository: WorkspaceRepository, + diagnosis_id: int = 501, +) -> AiConsultDialog: + dialog = AiConsultDialog(repository, _permissions()) + dialog.open_for(diagnosis_id=diagnosis_id, patient_id=diagnosis_id + 1000) + dialog.show() + application.processEvents() + return dialog + + +def test_case_tab_renders_complete_owned_detail_as_readable_chinese( + application: QApplication, + immediate_async: None, +) -> None: + dialog = _open_dialog(application, WorkspaceRepository()) + pane = dialog.records["病历资料"] + dialog.tabs.setCurrentIndex(1) + application.processEvents() + + assert pane.findChild(QWidget, "AiConsultCaseGrid") is not None + text = _pane_text(pane) + for sentinel in ( + "甲主诉口渴乏力", + "甲现病史半年血糖波动", + "甲既往高血压五年", + "甲青霉素过敏", + "甲父亲糖尿病", + "甲气阴两虚", + "甲2 型糖尿病", + "高血压", + "甲偏甜", + "夜宵", + "甲-RX-09", + "待配药", + ): + assert sentinel in text + assert "['" not in text + assert "{'" not in text + + scroll = pane.findChild(QScrollArea) + assert scroll is not None and scroll.widgetResizable() + assert pane.geometry().isValid() and scroll.viewport().geometry().isValid() + assert scroll.verticalScrollBar().maximum() > 0 + dialog.close() + + +def test_all_four_record_tabs_use_the_selected_diagnosis_id( + application: QApplication, + immediate_async: None, +) -> None: + repository = WorkspaceRepository(include_foreign_rows=False) + dialog = _open_dialog(application, repository, diagnosis_id=501) + + for method in ( + "get_diagnosis_detail", + "get_doctor_notes", + "get_tracking_window", + "list_prescriptions_by_diagnosis", + ): + assert (method, 501) in repository.calls + assert all( + called_id == 501 + for called_method, called_id in repository.calls + if called_method == method + ) + assert ("list_patient_ai_reports", 1501) in repository.calls + dialog.close() + + +def test_seed_cannot_replace_the_authoritative_patient_id( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + deferred = DeferredAsync() + monkeypatch.setattr(ai_consult_module, "run_async", deferred) + repository = WorkspaceRepository(include_foreign_rows=False) + dialog = AiConsultDialog(repository, _permissions()) + + dialog.open_for( + diagnosis_id=501, + patient_id=1501, + seed={"patient_id": 501, "patient_name": "错误种子"}, + ) + + assert dialog.patient_id == 1501 + deferred.complete(0) + application.processEvents() + assert dialog.patient_id == 1501 + assert ("list_patient_ai_reports", 1501) in repository.calls + dialog.close() + + +def test_detail_failure_or_wrong_owner_never_requests_patient_reports( + application: QApplication, + immediate_async: None, +) -> None: + failed_repository = WorkspaceRepository(include_foreign_rows=False) + failed_repository.failures.add(("get_diagnosis_detail", 501)) + failed = _open_dialog(application, failed_repository) + assert all( + method != "list_patient_ai_reports" + for method, _owner in failed_repository.calls + ) + failed.close() + + wrong_repository = WorkspaceRepository(include_foreign_rows=False) + wrong_repository.details[501] = _detail(999, "越权") + wrong = _open_dialog(application, wrong_repository) + assert all( + method != "list_patient_ai_reports" + for method, _owner in wrong_repository.calls + ) + wrong.close() + + +def test_patient_report_response_owner_must_match_exactly( + application: QApplication, + immediate_async: None, +) -> None: + repository = WorkspaceRepository(include_foreign_rows=False) + repository.report_payload = { + "patient_id": "1501", + "reports": [ + { + "patient_id": 1501, + "report": {"diagnosis": "不应显示的越权报告"}, + } + ], + } + dialog = _open_dialog(application, repository) + + assert ("list_patient_ai_reports", 1501) in repository.calls + assert "不应显示的越权报告" not in _pane_text(dialog) + assert not ai_consult_module._report_response_matches_patient( + repository.report_payload, + 1501, + ) + dialog.close() + + +def test_exam_tab_filters_foreign_attachments_and_blocks_file_urls( + application: QApplication, + immediate_async: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + opened: list[str] = [] + monkeypatch.setattr( + ai_consult_module, + "open_safe_http_url", + lambda target: opened.append(target) or True, + ) + dialog = _open_dialog(application, WorkspaceRepository()) + pane = dialog.records["检查检验"] + dialog.tabs.setCurrentIndex(2) + application.processEvents() + + assert pane.findChild(QWidget, "AiConsultExamTimeline") is not None + text = _pane_text(pane) + assert "甲舌苔照片.jpg" in text + assert "甲血糖报告.pdf" in text + assert "甲本地危险附件.pdf" in text + assert "错误诊单附件哨兵" not in text + assert "错误诊单舌苔.jpg" not in text + + buttons = pane.findChildren(QPushButton, "AiConsultMediaOpen") + assert len(buttons) == 3 + thumbnails = pane.findChildren(QPushButton, "AiConsultTongueThumb") + assert len(thumbnails) == 1 + assert thumbnails[0].isEnabled() + assert thumbnails[0].accessibleName() == "舌苔图片点击查看" + assert thumbnails[0].property("loadState") == "blocked" + assert pane.state_label.property("state") == "warning" # type: ignore[attr-defined] + assert pane.retry_button.isHidden() # type: ignore[attr-defined] + unsafe = next(button for button in buttons if "本地危险附件" in button.text()) + assert not unsafe.isEnabled() + for button in buttons: + button.click() + assert len(opened) == 2 + assert all(target.startswith(("http://", "https://")) for target in opened) + assert all(not target.startswith("file:") for target in opened) + dialog.close() + + +def test_tongue_thumbnail_auto_get_requires_configured_https_origin( + application: QApplication, + immediate_async: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + requested: list[str] = [] + + class RecordingRemoteImageButton(QPushButton): + def __init__(self, source: str, **kwargs: Any) -> None: + super().__init__(kwargs.get("parent")) + requested.append(source) + self.setObjectName(str(kwargs.get("object_name") or "")) + self.setAccessibleName( + str(kwargs.get("fallback_text") or "").replace("\n", "") + ) + + monkeypatch.setattr( + ai_consult_module, + "_RemoteImageButton", + RecordingRemoteImageButton, + ) + + untrusted = _open_dialog(application, WorkspaceRepository()) + assert requested == [] + untrusted.close() + + trusted_repository = WorkspaceRepository() + trusted_repository.trusted_media_domains = ["media.example.invalid"] + assert not ai_consult_module._trusted_thumbnail_url( + trusted_repository, + "http://media.example.invalid/甲/tongue.jpg", + ) + assert not ai_consult_module._trusted_thumbnail_url( + trusted_repository, + "https://sub.media.example.invalid/甲/tongue.jpg", + ) + trusted = _open_dialog(application, trusted_repository) + assert requested == ["https://media.example.invalid/甲/tongue.jpg"] + trusted.close() + + +def test_three_prescription_cards_open_exact_details_and_reject_wrong_or_late_ids( + application: QApplication, + immediate_async: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repository = WorkspaceRepository(include_foreign_rows=False) + opened: list[int] = [] + + class FakePrescriptionDetailDialog: + def __init__(self, prescription: Any, **_kwargs: Any) -> None: + self.prescription = prescription + + def exec(self) -> None: + opened.append(int(self.prescription["id"])) + + monkeypatch.setattr( + prescription_module, + "PrescriptionDetailDialog", + FakePrescriptionDetailDialog, + ) + dialog = _open_dialog(application, repository) + pane = dialog.records["处方记录"] + dialog.tabs.setCurrentIndex(3) + application.processEvents() + + cards = pane.findChildren(QWidget, "AiConsultPrescriptionCard") + buttons = sorted( + pane.findChildren(QPushButton, "AiConsultPrescriptionOpen"), + key=lambda button: int(button.property("prescriptionId")), + ) + expected_ids = [5011, 5012, 5013] + assert len(cards) == len(buttons) == 3 + assert [int(button.property("prescriptionId")) for button in buttons] == expected_ids + assert all(button.text() == "查看详情" for button in buttons) + for button in buttons: + button.click() + assert repository.prescription_detail_calls == expected_ids + assert opened == expected_ids + + repository.prescription_overrides[5011] = { + "id": 9999, + "diagnosis_id": 501, + } + buttons[0].click() + assert repository.prescription_detail_calls[-1] == 5011 + assert opened == expected_ids + repository.prescription_overrides.pop(5011) + + repository.prescription_overrides[5011] = {"id": 5011} + buttons[0].click() + assert repository.prescription_detail_calls[-1] == 5011 + assert opened == expected_ids + repository.prescription_overrides.pop(5011) + + calls_before_unowned_source = list(repository.prescription_detail_calls) + dialog._open_prescription_detail({"id": 5011}) + assert repository.prescription_detail_calls == calls_before_unowned_source + + deferred = DeferredAsync() + monkeypatch.setattr(ai_consult_module, "run_async", deferred) + buttons[0].click() + buttons[1].click() + assert len(deferred.pending) == 2 + deferred.complete(1) + application.processEvents() + deferred.complete(0) + application.processEvents() + assert repository.prescription_detail_calls[-2:] == [5012, 5011] + assert opened == [*expected_ids, 5012] + dialog.close() + + +def test_health_tab_masks_sensitive_patient_data_and_renders_tracking_window( + application: QApplication, + immediate_async: None, +) -> None: + dialog = _open_dialog(application, WorkspaceRepository()) + pane = dialog.records["健康档案"] + dialog.tabs.setCurrentIndex(4) + application.processEvents() + + assert pane.findChild(QWidget, "AiConsultHealthGrid") is not None + text = _pane_text(pane) + for sentinel in ( + "甲患者", + "甲杭州", + "甲健康路 8 号", + "138****8000", + "110***********1234", + "甲8.2", + "甲12.4", + "甲146", + "甲92", + "甲燕麦鸡蛋", + "甲杂粮饭", + "甲散步", + "35", + "甲气阴两虚", + "甲二甲双胍", + "阿卡波糖", + "不吸烟", + "甲易醒", + "多梦", + ): + assert sentinel in text + assert pane.findChild(QWidget, "AiConsultDiagnosisHealthSummary") is not None + assert "13800138000" not in text + assert "110105199203071234" not in text + assert "错误诊单血糖 19.9" not in text + assert pane.state_label.property("state") == "warning" # type: ignore[attr-defined] + assert pane.retry_button.isHidden() # type: ignore[attr-defined] + dialog.close() + + +def test_ownerless_notes_prescriptions_and_tracking_rows_fail_closed_as_warning( + application: QApplication, + immediate_async: None, +) -> None: + class OwnerlessRepository(WorkspaceRepository): + def get_doctor_notes(self, diagnosis_id: int) -> list[dict[str, Any]]: + rows = super().get_doctor_notes(diagnosis_id) + for row in rows: + row.pop("diagnosis_id", None) + return rows + + def list_prescriptions_by_diagnosis( + self, + diagnosis_id: int, + ) -> list[dict[str, Any]]: + rows = super().list_prescriptions_by_diagnosis(diagnosis_id) + for row in rows: + row.pop("diagnosis_id", None) + return rows + + def get_tracking_window(self, diagnosis_id: int) -> dict[str, Any]: + result = super().get_tracking_window(diagnosis_id) + for key in ("blood_records", "diet_records", "exercise_records"): + for row in result[key]: + row.pop("diagnosis_id", None) + return result + + dialog = _open_dialog( + application, + OwnerlessRepository(include_foreign_rows=False), + ) + exam = dialog.records["检查检验"] + prescriptions = dialog.records["处方记录"] + health = dialog.records["健康档案"] + + assert "甲医生检查记录" not in _pane_text(exam) + assert not prescriptions.findChildren(QWidget, "AiConsultPrescriptionCard") + assert "甲燕麦鸡蛋" not in _pane_text(health) + for pane in (exam, prescriptions, health): + assert pane.state_label.property("state") == "warning" # type: ignore[attr-defined] + assert pane.retry_button.isHidden() # type: ignore[attr-defined] + dialog.close() + + +def test_tracking_response_without_diagnosis_owner_is_filtered_without_retry( + application: QApplication, + immediate_async: None, +) -> None: + class OwnerlessTrackingRepository(WorkspaceRepository): + def get_tracking_window(self, diagnosis_id: int) -> dict[str, Any]: + result = super().get_tracking_window(diagnosis_id) + result.pop("diagnosis_id", None) + return result + + dialog = _open_dialog( + application, + OwnerlessTrackingRepository(include_foreign_rows=False), + ) + pane = dialog.records["健康档案"] + + assert "甲燕麦鸡蛋" not in _pane_text(pane) + assert pane.state_label.property("state") == "warning" # type: ignore[attr-defined] + assert pane.retry_button.isHidden() # type: ignore[attr-defined] + dialog.close() + + +def test_late_workspace_a_response_cannot_pollute_selected_workspace_b( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repository = WorkspaceRepository(include_foreign_rows=False) + deferred = DeferredAsync() + monkeypatch.setattr(ai_consult_module, "run_async", deferred) + dialog = AiConsultDialog(repository, _permissions()) + dialog.open_for(diagnosis_id=501, patient_id=1501) + dialog.open_for(diagnosis_id=502, patient_id=1502) + dialog.show() + assert len(deferred.pending) == 2 + + deferred.complete(1) + application.processEvents() + deferred.complete(0) + application.processEvents() + for title in ("病历资料", "检查检验", "处方记录", "健康档案"): + text = _pane_text(dialog.records[title]) + assert "乙" in text + assert "甲主诉口渴乏力" not in text + assert "甲医生检查记录" not in text + assert "甲-RX-1" not in text + assert "甲燕麦鸡蛋" not in text + assert dialog.diagnosis_id == 502 + assert dialog._detail["diagnosis"]["id"] == 502 + dialog.close() + + +def test_mismatched_detail_owner_fails_closed_across_all_record_tabs( + application: QApplication, + immediate_async: None, +) -> None: + repository = WorkspaceRepository() + repository.details[501] = _detail(999, "越权") + dialog = _open_dialog(application, repository) + + forbidden = ( + "越权主诉口渴乏力", + "甲医生检查记录", + "甲舌苔照片.jpg", + "甲-RX-1", + "甲燕麦鸡蛋", + ) + for title in ("病历资料", "检查检验", "处方记录", "健康档案"): + pane = dialog.records[title] + text = _pane_text(pane) + assert all(sentinel not in text for sentinel in forbidden) + state = pane.state_label # type: ignore[attr-defined] + retry = pane.retry_button # type: ignore[attr-defined] + assert state.objectName() == "AiConsultRecordState" + assert retry.objectName() == "AiConsultRecordRetry" + assert state is not None and state.property("state") == "warning" + assert retry is not None and retry.isHidden() + assert all( + method != "list_patient_ai_reports" for method, _owner in repository.calls + ) + dialog.close() + + +@pytest.mark.parametrize( + ("method", "error_tabs", "success_sentinels"), + [ + ( + "get_diagnosis_detail", + {"病历资料", "健康档案"}, + {"检查检验": "甲医生检查记录", "处方记录": "甲-RX-1"}, + ), + ( + "get_doctor_notes", + {"检查检验"}, + { + "病历资料": "甲主诉口渴乏力", + "处方记录": "甲-RX-1", + "健康档案": "甲燕麦鸡蛋", + }, + ), + ( + "get_tracking_window", + {"健康档案"}, + { + "病历资料": "甲主诉口渴乏力", + "检查检验": "甲医生检查记录", + "处方记录": "甲-RX-1", + }, + ), + ( + "list_prescriptions_by_diagnosis", + {"处方记录"}, + { + "病历资料": "甲主诉口渴乏力", + "检查检验": "甲医生检查记录", + "健康档案": "甲燕麦鸡蛋", + }, + ), + ], +) +def test_one_failed_source_has_local_error_retry_and_preserves_other_sections( + application: QApplication, + immediate_async: None, + method: str, + error_tabs: set[str], + success_sentinels: dict[str, str], +) -> None: + repository = WorkspaceRepository(include_foreign_rows=False) + repository.failures.add((method, 501)) + dialog = _open_dialog(application, repository) + + for title in error_tabs: + pane = dialog.records[title] + state = pane.state_label # type: ignore[attr-defined] + retry = pane.retry_button # type: ignore[attr-defined] + assert state.objectName() == "AiConsultRecordState" + assert retry.objectName() == "AiConsultRecordRetry" + assert state is not None and state.property("state") == "error" + assert retry is not None and not retry.isHidden() and retry.isEnabled() + for title, sentinel in success_sentinels.items(): + assert sentinel in _pane_text(dialog.records[title]) + state = dialog.records[title].state_label # type: ignore[attr-defined] + assert state is not None and state.property("state") != "error", ( + title, + state.text(), + state.property("state"), + ) + + repository.failures.clear() + dialog.records[next(iter(error_tabs))].retry_button.click() # type: ignore[attr-defined] + application.processEvents() + for pane in dialog.records.values(): + state = pane.state_label # type: ignore[attr-defined] + assert state is not None and state.property("state") != "error" + dialog.close() diff --git a/app/tests/test_api_client.py b/app/tests/test_api_client.py index 5f3dc4a9b..ea3b984d5 100644 --- a/app/tests/test_api_client.py +++ b/app/tests/test_api_client.py @@ -74,6 +74,52 @@ def test_post_uses_json_and_never_retries_timeout() -> None: assert caught.value.data["attempts"] == 1 +def test_post_event_stream_sends_exact_contract_and_preserves_event_order() -> None: + requests: list[httpx.Request] = [] + content = ( + 'event: start\ndata: {"model_key":"qwen"}\n\n' + 'event: delta\ndata: {"content":"辨"}\n\n' + 'event: delta\ndata: {"content":"证"}\n\n' + 'event: done\ndata: {"model_label":"千问"}\n\n' + ) + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + headers={"content-type": "text/event-stream; charset=utf-8"}, + text=content, + ) + + client = ApiClient( + "https://example.test", + token="stream-token", + transport=httpx.MockTransport(handler), + ) + events = list( + client.post_event_stream( + "tcm.diagnosis/aiAssistantStream", + {"id": 501, "task": "custom", "prompt": "如何辨证?"}, + ) + ) + client.close() + + assert [event["event"] for event in events] == ["start", "delta", "delta", "done"] + assert [event["data"] for event in events[1:3]] == [ + {"content": "辨"}, + {"content": "证"}, + ] + request = requests[0] + assert request.headers["accept"] == "text/event-stream" + assert request.headers["token"] == "stream-token" + assert str(request.url).endswith("/adminapi/tcm.diagnosis/aiAssistantStream") + assert json.loads(request.content) == { + "id": 501, + "task": "custom", + "prompt": "如何辨证?", + } + + def test_multipart_post_lets_httpx_set_boundary_and_sends_form_fields( tmp_path: Path, ) -> None: diff --git a/app/tests/test_appointments_parity_ui.py b/app/tests/test_appointments_parity_ui.py index b894546e0..a6f4aae0f 100644 --- a/app/tests/test_appointments_parity_ui.py +++ b/app/tests/test_appointments_parity_ui.py @@ -3,13 +3,14 @@ from __future__ import annotations import os +from copy import deepcopy from types import SimpleNamespace from typing import Any os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest -from PySide6.QtWidgets import QApplication, QDialog, QDialogButtonBox, QLabel +from PySide6.QtWidgets import QAbstractItemView, QApplication, QDialog, QDialogButtonBox, QLabel from doctor_workstation.core.errors import ApiProtocolError from doctor_workstation.core.models import Appointment, PageResult @@ -163,6 +164,8 @@ def test_diagnosis_and_patient_ids_stay_distinct_for_video() -> None: ) assert _diagnosis_id(row) == 501 assert _video_patient_id(row) == 301 + assert _video_patient_id({"diagnosis_id": 501, "patient_id": 301}) == 0 + assert _video_patient_id({"diagnosis_id": 501, "patient_id": 501}) == 0 assert prescription_action_label(row) == "开方" approved = Appointment.from_dict( @@ -354,7 +357,8 @@ def test_appointment_multiline_cells_receive_enough_row_height( assert appointment_text.count("\n") == 2 assert "2026-08-11 14:30" in appointment_text required = 3 * max(16, page.table.fontMetrics().lineSpacing()) + 10 - assert page.table.rowHeight(0) >= required + assert 60 <= page.table.rowHeight(0) <= 66 + assert page.table.rowHeight(0) >= min(required, 66) assert page.table.item(0, 4).toolTip() == appointment_text page.close() @@ -513,6 +517,7 @@ def test_appointments_reference_split_layout_and_video_list( current_user={"id": 1001, "role_id": 1}, ) page.resize(1460, 820) + page._apply_responsive_layout() page._loaded( { "lists": [ @@ -538,8 +543,133 @@ def test_appointments_reference_split_layout_and_video_list( assert page.video_list.count() == 1 assert "赵俊霞" in page.video_list.item(0).text() - assert page.video_list.parentWidget().width() == 420 + assert 300 <= page.video_panel.width() <= 420 assert page.table.objectName() == "AppointmentTable" + assert ( + page.table.verticalScrollMode() + == QAbstractItemView.ScrollMode.ScrollPerPixel + ) + assert ( + page.video_list.verticalScrollMode() + == QAbstractItemView.ScrollMode.ScrollPerPixel + ) assert page.date_buttons["today"].isChecked() + + page.resize(1024, 640) + page._apply_responsive_layout() + assert page.video_panel.isHidden() + assert not page.video_panel_button.isHidden() + assert not page.date_overflow_button.isHidden() + assert page.date_buttons["yesterday"].isHidden() + assert not page.date_buttons["today"].isHidden() + page.video_panel_button.click() + assert not page.video_panel.isHidden() + assert 250 <= page.video_panel.width() <= 300 + page.video_panel_button.click() + assert page.video_panel.isHidden() + page.close() + application.processEvents() + + +def test_identical_appointment_poll_keeps_existing_cell_widgets( + application: QApplication, +) -> None: + page = AppointmentsPage( + DemoDoctorRepository(), + permissions=PermissionSet(["doctor.appointment/lists"]), + current_user={"id": 1001, "role_id": 1}, + ) + result = { + "lists": [ + { + "id": 101, + "diagnosis_id": 501, + "patient_id": 301, + "patient_name": "赵俊霞", + "gender": 2, + "age": 53, + "assistant_name": "周医助", + "appointment_date": "2026-08-17", + "appointment_time": "09:50", + "status": 1, + "status_desc": "已挂号", + } + ], + "count": 1, + "extend": {"status_count": {"1": 1}}, + } + page._loaded(result, page._generation, True) + selector = page.table.cellWidget(0, 0) + appointment_info = page.table.cellWidget(0, 4) + video_card = page.video_list.itemWidget(page.video_list.item(0)) + + page._loaded(deepcopy(result), page._generation, True) + + assert page.table.cellWidget(0, 0) is selector + assert page.table.cellWidget(0, 4) is appointment_info + assert page.video_list.itemWidget(page.video_list.item(0)) is video_card + + changed = deepcopy(result) + changed["lists"][0]["assistant_name"] = "新医助" + page._loaded(changed, page._generation, True) + assert page.table.cellWidget(0, 0) is not selector + assert page.table.cellWidget(0, 4) is not appointment_info + page.close() + application.processEvents() + + +def test_appointments_density_fits_four_rows_in_1366_shell_viewport( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(appointments_module, "run_async", lambda *_args, **_kwargs: object()) + page = AppointmentsPage( + DemoDoctorRepository(), + permissions=PermissionSet(["*"]), + current_user={"id": 1001, "role_id": 1}, + ) + # 1366x768 shell minus its 179 px appointment rail, 26 px outer gutter, + # and 62 px top bar leaves a 1161x680 page viewport. + page.resize(1161, 680) + page.show() + application.processEvents() + rows = [ + { + "id": 100 + index, + "diagnosis_id": 500 + index, + "patient_id": 300 + index, + "patient_name": f"患者{index}", + "gender": 2, + "age": 40 + index, + "doctor_name": "陈医生", + "assistant_name": "周医助", + "appointment_date": "2026-08-17", + "appointment_time": f"{8 + index:02d}:00", + "status": 1, + "status_desc": "已挂号", + "diagnosis_confirmed": 0, + "has_prescription": 0, + } + for index in range(8) + ] + page._loaded( + {"lists": rows, "count": len(rows), "extend": {"status_count": {"1": 8}}}, + page._generation, + False, + ) + application.processEvents() + + heights = [page.table.rowHeight(index) for index in range(page.table.rowCount())] + assert page.header.height() == 26 + assert page.filter_panel.height() <= 84 + assert all(60 <= height <= 66 for height in heights) + assert page.table.viewport().height() // max(heights) >= 4 + assert page.pager.isVisibleTo(page) + assert 300 <= page.video_panel.width() < 420 + + page.resize(1024, 640) + application.processEvents() + assert page.video_panel.isHidden() + assert not page.video_panel_button.isHidden() page.close() application.processEvents() diff --git a/app/tests/test_consultations_parity_ui.py b/app/tests/test_consultations_parity_ui.py index e46cfa2a2..8db774af0 100644 --- a/app/tests/test_consultations_parity_ui.py +++ b/app/tests/test_consultations_parity_ui.py @@ -298,7 +298,7 @@ def test_action_visibility_requires_exact_canonical_permissions( application.processEvents() -def test_refresh_generation_ignores_late_results( +def test_refresh_generation_ignores_late_results( application: QApplication, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -318,11 +318,81 @@ def test_refresh_generation_ignores_late_results( application.processEvents() assert page.table.rowCount() == 1 - assert page.table.item(0, 0).text().startswith("902") - page.close() - - -def test_current_appointment_is_the_only_prescription_authority( + assert page.table.item(0, 0).text().startswith("902") + page.close() + + +def test_identical_silent_refresh_has_zero_model_reset_and_fixed_widget_budget( + application: QApplication, + immediate_async: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class Repository: + calls = 0 + + def list_consultations(self, **_kwargs: Any) -> dict[str, Any]: + self.calls += 1 + return {"lists": [_row()], "count": 1} + + repository = Repository() + page = ConsultationsPage(repository, permissions=PermissionSet(["*"])) + page.refresh(silent=True) + page.table.selectRow(0) + model = page.table_host.model + action_widget = page.table_host.fixed.indexWidget(model.index(0, 11)) + video_widget = page.table_host.fixed.indexWidget(model.index(0, 10)) + resets: list[str] = [] + model.modelAboutToBeReset.connect(lambda: resets.append("begin")) + model.modelReset.connect(lambda: resets.append("end")) + install_calls: list[None] = [] + original_install = page.table_host._install_fixed_widgets + + def count_install() -> None: + install_calls.append(None) + original_install() + + monkeypatch.setattr(page.table_host, "_install_fixed_widgets", count_install) + page.refresh(silent=True) + + assert repository.calls == 2 + assert resets == [] + assert install_calls == [] + assert page.table_host.fixed.indexWidget(model.index(0, 11)) is action_widget + assert page.table_host.fixed.indexWidget(model.index(0, 10)) is video_widget + assert page.table.currentIndex().row() == 0 + page.close() + application.processEvents() + + +def test_timer_poll_has_one_request_budget_while_refresh_is_in_flight( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + jobs: list[dict[str, Any]] = [] + + def queue_async(_function: Any, **options: Any) -> object: + jobs.append(options) + return object() + + monkeypatch.setattr(consultations_module, "run_async", queue_async) + page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"])) + monkeypatch.setattr(page, "isVisible", lambda: True) + monkeypatch.setattr(page, "_refresh_counts", lambda: None) + + page._poll_refresh() + page._poll_refresh() + page._poll_refresh() + + assert len(jobs) == 1 + assert page._loading + jobs[0]["on_success"]({"lists": [_row()], "count": 1}) + jobs[0]["on_finished"]() + assert not page._loading + page.close() + application.processEvents() + + +def test_current_appointment_is_the_only_prescription_authority( application: QApplication, ) -> None: calls: list[tuple[str, int]] = [] diff --git a/app/tests/test_diagnosis_drawer_visual.py b/app/tests/test_diagnosis_drawer_visual.py index 58117b9f4..418a4eb02 100644 --- a/app/tests/test_diagnosis_drawer_visual.py +++ b/app/tests/test_diagnosis_drawer_visual.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from datetime import date, timedelta from pathlib import Path from typing import Any @@ -276,13 +277,15 @@ class VisualRepository: ) -> dict[str, Any]: assert diagnosis_id == 501 self.tracking_calls.append((start_date, end_date)) + newest_date = end_date or date.today().isoformat() + previous_date = (date.fromisoformat(newest_date) - timedelta(days=1)).isoformat() return { "blood_records": [ { "id": 6201, "diagnosis_id": 501, "patient_id": 1501, - "record_date": "2026-08-10", + "record_date": newest_date, "fasting_blood_sugar": 8.2, "systolic_pressure": 146, "source": 1, @@ -291,7 +294,7 @@ class VisualRepository: "id": 6202, "diagnosis_id": 501, "patient_id": 1501, - "record_date": "2026-08-10", + "record_date": newest_date, "postprandial_blood_sugar": 12.4, "diastolic_pressure": 92, "western_medicine": "二甲双胍", @@ -300,7 +303,7 @@ class VisualRepository: "id": 6203, "diagnosis_id": 501, "patient_id": 1501, - "record_date": "2026-08-09", + "record_date": previous_date, "fasting_blood_sugar": 7.6, "postprandial_blood_sugar": 10.8, }, @@ -310,7 +313,7 @@ class VisualRepository: "id": 6301, "diagnosis_id": 501, "patient_id": 1501, - "record_date": "2026-08-10", + "record_date": newest_date, "breakfast_foods": "燕麦、鸡蛋", "lunch_foods": "杂粮饭", } @@ -320,7 +323,7 @@ class VisualRepository: "id": 6401, "diagnosis_id": 501, "patient_id": 1501, - "record_date": "2026-08-09", + "record_date": previous_date, "exercise_type": "散步", "duration": 35, "intensity": 2, @@ -331,7 +334,7 @@ class VisualRepository: def list_tracking_notes(self, diagnosis_id: int) -> list[dict[str, Any]]: assert diagnosis_id == 501 self.tracking_note_calls += 1 - return [{"note_date": "2026-08-10", "content": "饭后散步,继续观察。"}] + return [{"note_date": date.today().isoformat(), "content": "饭后散步,继续观察。"}] def list_diagnosis_todos( self, @@ -871,10 +874,11 @@ def test_tabs_lazy_load_real_repository_data_and_daily_matrix_structure( panel = dialog._daily_panels[1] assert panel.matrix.rowCount() == 11 assert panel.matrix.columnCount() == 8 + newest_header = repository.tracking_calls[-1][1][5:] blood_column = next( column for column in range(1, panel.matrix.columnCount()) - if panel.matrix.horizontalHeaderItem(column).text() == "08-10" + if panel.matrix.horizontalHeaderItem(column).text() == newest_header ) assert panel.matrix.item(0, blood_column).text() == "↑ 8.2 · 自录" assert panel.matrix.item(3, blood_column).text() == "↑ 146/92 · 自录" @@ -1270,15 +1274,18 @@ def test_existing_daily_cells_edit_real_records_and_reject_wrong_owner( return payload monkeypatch.setattr(diagnosis_module, "DailyRecordEditorDialog", AcceptedEditor) + newest_date = date.fromisoformat(repository.tracking_calls[-1][1]) + newest_header = newest_date.strftime("%m-%d") + previous_header = (newest_date - timedelta(days=1)).strftime("%m-%d") blood_column = next( column for column in range(1, panel.matrix.columnCount()) - if panel.matrix.horizontalHeaderItem(column).text() == "08-10" + if panel.matrix.horizontalHeaderItem(column).text() == newest_header ) exercise_column = next( column for column in range(1, panel.matrix.columnCount()) - if panel.matrix.horizontalHeaderItem(column).text() == "08-09" + if panel.matrix.horizontalHeaderItem(column).text() == previous_header ) blood_role = panel.matrix.item(0, blood_column).data(Qt.ItemDataRole.UserRole) diet_role = panel.matrix.item(6, blood_column).data(Qt.ItemDataRole.UserRole) diff --git a/app/tests/test_diagnosis_index_visual.py b/app/tests/test_diagnosis_index_visual.py index b475a4c6c..5b5c23079 100644 --- a/app/tests/test_diagnosis_index_visual.py +++ b/app/tests/test_diagnosis_index_visual.py @@ -8,9 +8,16 @@ from typing import Any os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest -from PySide6.QtCore import QAbstractTableModel, QRect, Qt, Signal +from PySide6.QtCore import QAbstractTableModel, QPoint, QRect, Qt, Signal from PySide6.QtGui import QColor, QImage, QPainter -from PySide6.QtWidgets import QApplication, QFrame, QToolButton, QWidget +from PySide6.QtWidgets import ( + QAbstractItemView, + QApplication, + QFrame, + QSizePolicy, + QToolButton, + QWidget, +) from doctor_workstation.core import PermissionSet from doctor_workstation.ui.diagnosis_index_widgets import ( @@ -158,14 +165,15 @@ def test_visual_hierarchy_and_filter_contract( page = _page() content_layout = page.page_scroll.widget().layout() margins = content_layout.contentsMargins() - assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (20, 18, 29, 16) - assert content_layout.spacing() == 12 + assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (18, 10, 18, 10) + assert content_layout.spacing() == 8 status_card = page.findChild(QFrame, "DiagnosisStatusCard") assert status_card is not None - assert status_card.height() == 62 + assert page.page_header.height() == 62 + assert status_card.height() == 50 assert page.findChild(QFrame, "DiagnosisFilterCard") is not None assert page.findChild(QFrame, "DiagnosisListCard") is not None - assert page.filters_card.height() == 108 + assert page.filters_card.height() == 90 assert page.keyword_edit.maximumWidth() == 380 assert list(page.status_buttons) == ["1", "", "4", "2", "3"] assert page.status_buttons["1"].isChecked() @@ -234,12 +242,17 @@ def test_dedicated_model_fixed_columns_selection_and_sort( assert isinstance(page.table.model(), QAbstractTableModel) assert isinstance(page.table.model(), DiagnosisTableModel) assert page.table_host.LEFT_WIDTHS == (48, 70, 60, 100, 175, 88, 120, 100, 72, 110) - assert page.table_host.FIXED_WIDTHS == (120, 340) - assert page.table_host.fixed.width() == 462 + assert page.table_host.FIXED_WIDTHS == (120, 410) + assert page.table_host.fixed.width() == 532 assert page.table.isColumnHidden(10) assert page.table_host.fixed.isColumnHidden(9) assert not page.table_host.fixed.isColumnHidden(10) - assert page.table.verticalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff + assert page.table.verticalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAsNeeded + assert page.table.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel + assert page.table_host.fixed.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel + assert page.table_host.fixed.verticalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAlwaysOff + assert page.table_host.minimumHeight() == 0 + assert page.table_host.sizePolicy().verticalPolicy() == QSizePolicy.Policy.Expanding rows = [_row(501), _row(502, has_appointment=0, appointments=[])] page.table_host.set_rows(rows) @@ -303,6 +316,10 @@ def test_empty_loading_and_full_pager_keep_the_table_shell( page.loading_overlay.stop() page.pager.update_state(3, 97) + assert 40 <= page.pager.height() <= 44 + pager_margins = page.pager.layout().contentsMargins() + assert pager_margins.top() >= 4 + assert pager_margins.bottom() >= 4 assert [page.pager.size_combo.itemData(index) for index in range(4)] == [15, 20, 30, 40] assert len([button for button in page.pager._page_buttons if not button.isHidden()]) == 5 assert page.pager.jumper.maximum() == 7 @@ -472,6 +489,7 @@ def test_full_more_menu_requires_each_real_repository_capability( "view": True, "edit": True, "prescription": True, + "ai_consult": True, "appointment": True, "assign": True, "delete": True, @@ -612,30 +630,106 @@ def test_error_state_is_persistent_until_rows_replace_it(application: QApplicati application.processEvents() -@pytest.mark.parametrize("size", [(1024, 640), (1440, 900)]) -def test_two_desktop_sizes_scroll_vertically_without_horizontal_page_clipping( +@pytest.mark.parametrize( + ("size", "minimum_visible_rows"), + [((1366, 768), 4), ((1710, 920), 7)], +) +def test_two_desktop_sizes_keep_pager_visible_and_scroll_rows_inside_table( application: QApplication, size: tuple[int, int], + minimum_visible_rows: int, ) -> None: page = _page() - rows = [_row(600 + index, patient_name=f"患者{index:02d}") for index in range(15)] + rows = [ + _row( + 600 + index, + patient_name=f"患者{index:02d}", + latest_appointment_channel_text="健康顾问转介", + ) + for index in range(40) + ] page.table_host.set_rows(rows) page.resize(*size) page.show() - application.processEvents() + for _ in range(4): + application.processEvents() + viewport = page.table.viewport() + visible_rows = sum( + 1 + for row in range(page.table_host.model.rowCount()) + if ( + (rect := page.table.visualRect(page.table_host.model.index(row, 0))).isValid() + and rect.top() >= 0 + and rect.bottom() < viewport.height() + ) + ) + pager_top = page.pager.mapTo(page.page_scroll.viewport(), QPoint()).y() assert page.page_scroll.horizontalScrollBar().maximum() == 0 - assert page.page_scroll.verticalScrollBar().maximum() > 0 + assert page.page_scroll.verticalScrollBar().maximum() == 0 + assert pager_top >= 0 + assert pager_top + page.pager.height() <= page.page_scroll.viewport().height() + assert visible_rows >= minimum_visible_rows + assert page.table.verticalScrollBar().maximum() > 0 assert page.table_host.fixed.geometry().right() <= page.table_host.rect().right() assert page.search_button.geometry().right() <= page.search_button.parentWidget().rect().right() page.close() application.processEvents() +def test_frozen_rows_track_main_pixel_scroll_and_host_height_is_page_size_stable( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + page = _page() + rows = [ + _row( + 800 + index, + latest_appointment_channel_text="健康顾问转介", + ) + for index in range(40) + ] + page.table_host.set_rows(rows[:15]) + page.resize(1366, 768) + page.show() + for _ in range(4): + application.processEvents() + host_height = page.table_host.height() + + monkeypatch.setattr(page, "refresh", lambda silent=False: None) + page._change_page_size(40) + page.table_host.set_rows(rows) + for _ in range(4): + application.processEvents() + + main_scroll = page.table.verticalScrollBar() + fixed_scroll = page.table_host.fixed.verticalScrollBar() + assert page.table_host.height() == host_height + assert main_scroll.maximum() == fixed_scroll.maximum() + main_scroll.setValue(main_scroll.maximum() // 2) + application.processEvents() + assert fixed_scroll.value() == main_scroll.value() + fixed_scroll.setValue(fixed_scroll.maximum() // 3) + application.processEvents() + assert main_scroll.value() == fixed_scroll.value() + + center_index = page.table.indexAt(page.table.viewport().rect().center()) + assert center_index.isValid() + main_top = page.table.visualRect(page.table_host.model.index(center_index.row(), 0)).top() + fixed_top = page.table_host.fixed.visualRect( + page.table_host.model.index(center_index.row(), 10) + ).top() + assert main_top == fixed_top + page.close() + application.processEvents() + + def test_required_reference_artifacts_exist() -> None: root = Path(__file__).resolve().parents[1] expected = { root / "artifacts" / "diagnosis_visual" / "diagnosis_1024x640.png": (1024, 640), root / "artifacts" / "diagnosis_visual" / "diagnosis_1440x900.png": (1440, 900), + root / "artifacts" / "diagnosis_visual" / "diagnosis_1366x768.png": (1366, 768), + root / "artifacts" / "diagnosis_visual" / "diagnosis_1710x920.png": (1710, 920), root / "artifacts" / "diagnosis_visual" / "diagnosis_loading_1280x800.png": ( 1280, 800, diff --git a/app/tests/test_diagnosis_media_thumbnail_visual.py b/app/tests/test_diagnosis_media_thumbnail_visual.py index 657d2ede3..d7135cf81 100644 --- a/app/tests/test_diagnosis_media_thumbnail_visual.py +++ b/app/tests/test_diagnosis_media_thumbnail_visual.py @@ -7,7 +7,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QObject, QSize, Signal from PySide6.QtGui import QColor, QImage -from PySide6.QtNetwork import QNetworkReply +from PySide6.QtNetwork import QNetworkReply, QNetworkRequest from PySide6.QtWidgets import QApplication, QPushButton, QWidget from doctor_workstation.ui.diagnosis_drawer import ( @@ -34,6 +34,7 @@ def _png_bytes(width: int, height: int, color: str = "#0F766E") -> bytes: class _FakeReply(QObject): finished = Signal() + downloadProgress = Signal(int, int) def __init__( self, @@ -45,6 +46,7 @@ class _FakeReply(QObject): self.payload = payload self.network_error = error self.aborted = False + self.read_all_calls = 0 def abort(self) -> None: self.aborted = True @@ -53,6 +55,7 @@ class _FakeReply(QObject): return self.network_error def readAll(self) -> QByteArray: # noqa: N802 - mirrors QNetworkReply + self.read_all_calls += 1 return QByteArray(self.payload) @@ -61,6 +64,7 @@ class _FakeManager(QObject): super().__init__(parent) self.responses: list[tuple[bytes, QNetworkReply.NetworkError]] = [] self.requests: list[str] = [] + self.request_objects: list[object] = [] self.replies: list[_FakeReply] = [] def queue( @@ -73,6 +77,7 @@ class _FakeManager(QObject): def get(self, request: object) -> _FakeReply: payload, error = self.responses.pop(0) self.requests.append(request.url().toString()) + self.request_objects.append(request) reply = _FakeReply(payload, error, self) self.replies.append(reply) return reply @@ -174,6 +179,45 @@ def test_remote_image_uses_text_only_after_request_or_decode_failure( assert application.thread() == button.thread() +def test_remote_image_enforces_same_origin_redirects_and_aborts_oversize_download( + application: QApplication, +) -> None: + owner = _RenderOwner(5) + button = _RemoteImageButton( + "", + render_owner=owner, + owner_generation=5, + maximum_size=QSize(64, 64), + fallback_text="image unavailable", + cover=True, + object_name="DiagnosisTongueThumb", + parent=owner, + ) + button._manager.deleteLater() + manager = _FakeManager(button) + button._manager = manager + manager.queue(b"must-not-be-read") + + button.load_url("https://media.example.invalid/oversize.png") + request = manager.request_objects[-1] + assert request.attribute(QNetworkRequest.Attribute.RedirectPolicyAttribute) == ( + QNetworkRequest.RedirectPolicy.SameOriginRedirectPolicy + ) + + reply = manager.replies[-1] + reply.downloadProgress.emit(button._MAX_IMAGE_BYTES, -1) + assert reply.aborted is False + reply.downloadProgress.emit(button._MAX_IMAGE_BYTES + 1, -1) + assert reply.aborted is True + assert reply.property("diagnosisImageOversize") is True + + reply.finished.emit() + assert reply.read_all_calls == 0 + assert button.property("loadState") == "failed" + assert button.text() == "image unavailable" + assert application.thread() == button.thread() + + def test_notes_render_cover_thumbnail_and_keep_safe_open_and_single_delete( application: QApplication, monkeypatch: pytest.MonkeyPatch, diff --git a/app/tests/test_patient_ai_report_desktop.py b/app/tests/test_patient_ai_report_desktop.py index c3ef05e46..35c6fdeee 100644 --- a/app/tests/test_patient_ai_report_desktop.py +++ b/app/tests/test_patient_ai_report_desktop.py @@ -3,13 +3,14 @@ from __future__ import annotations import os +from copy import deepcopy from datetime import date from typing import Any os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") import pytest -from PySide6.QtWidgets import QApplication +from PySide6.QtWidgets import QApplication, QLabel, QScrollArea from doctor_workstation.core import PermissionSet from doctor_workstation.services.mock_repository import DemoDoctorRepository @@ -18,7 +19,9 @@ from doctor_workstation.ui.pages import reception as reception_module from doctor_workstation.ui.pages.reception import ( AI_MEDICAL_DISCLAIMER, ReceptionPage, + _ai_narrative_text, _generated_patient_report, + _normalize_patient_report, _patient_report_rows, _ReceptionAiAnalysisDialog, ) @@ -596,3 +599,187 @@ def test_patient_ai_disclaimer_remains_the_unified_text() -> None: "仅供临床辅助参考,不可替代医生诊断,不得直接用于开方、用药调整或其他医疗决策。" "系统未对舌像、报告附件或视频画面进行视觉诊断;仅分析已录入、归档或转写的文字及附件元数据。" ) + + +def test_ai_narrative_formatter_preserves_lists_arrays_and_medical_numbers() -> None: + diagnosis: list[Any] = [ + "2型糖尿病,HbA1c 7.5%,当前控制未达标。", + {"text": r"二甲双胍 0.5g,每日2次。\n复查肾功能。"}, + "建议:1. 监测空腹血糖 2. 记录餐后2小时血糖", + ] + original = deepcopy(diagnosis) + + rendered = _ai_narrative_text(diagnosis) + + assert diagnosis == original + assert rendered == _ai_narrative_text(rendered) + assert rendered.splitlines() == [ + "• 2型糖尿病,HbA1c 7.5%,当前控制未达标。", + "• 二甲双胍 0.5g,每日2次。", + "复查肾功能。", + "• 建议:", + "1. 监测空腹血糖", + "2. 记录餐后2小时血糖", + ] + assert "7.5%" in rendered + assert "0.5g" in rendered + assert "2型糖尿病" in rendered + assert "7.\n5" not in rendered + assert "0.\n5" not in rendered + assert "2\n型糖尿病" not in rendered + + payload = { + "model_key": "qwen", + "diagnosis_advice": diagnosis, + "treatment_advice": ["控制总热量", "规律复诊"], + "risk_assessment": ["低血糖风险", {"label": "依从性风险", "level": "medium"}], + } + payload_before = deepcopy(payload) + normalized = _normalize_patient_report(payload) + + assert payload == payload_before + assert normalized is not None + assert normalized["diagnosis_advice"] == rendered + assert normalized["treatment_advice"] == "• 控制总热量\n• 规律复诊" + assert normalized["risk_assessment"] == [ + {"label": "低血糖风险", "level": "low"}, + {"label": "依从性风险", "level": "medium"}, + ] + + +def test_patient_report_dialog_uses_one_scroll_owner_and_wrapped_risk_flow( + application: QApplication, +) -> None: + long_risk = ( + "这是一个需要换行展示的较长风险项目,用于验证标签不会超出正文区域," + "并且能够在流式布局中可靠折行。" + ) + payload = { + "model_key": "qwen", + "model_label": "千问", + "generated_at": "2026-08-17 10:20:00", + "diagnosis_advice": [ + "2型糖尿病,HbA1c 7.5%,建议继续分层监测。", + "1. 监测空腹血糖 2. 记录餐后2小时血糖", + ] + * 10 + + ["[诊断末尾]"], + "risk_assessment": [ + {"label": "低血糖", "level": "high"}, + {"label": "依从性风险", "level": "medium"}, + {"label": "并发症筛查延误风险", "level": "low"}, + {"label": "复诊中断风险", "level": "medium"}, + {"label": long_risk, "level": "high"}, + {"label": "饮食波动风险", "level": "low"}, + ], + "treatment_advice": [r"二甲双胍 0.5g,每日2次。\n复查肾功能。"] * 12 + + ["[治疗末尾]"], + } + dialog = _ReceptionAiAnalysisDialog({"qwen": [payload]}) + dialog.resize(720, 560) + dialog.show() + application.processEvents() + + assert dialog.minimumWidth() == 720 + assert dialog.minimumHeight() == 560 + scrolls = dialog.findChildren(QScrollArea) + assert scrolls == [dialog.scroll_area] + assert dialog.scroll_area.horizontalScrollBar().maximum() == 0 + assert dialog.scroll_area.verticalScrollBar().maximum() > 0 + body = dialog.scroll_area.widget() + assert body is not None and body.layout() is not None + assert body.height() <= max( + dialog.scroll_area.viewport().height(), + body.layout().sizeHint().height(), + ) + 40 + assert dialog.diagnosis_label.text().endswith("[诊断末尾]") + assert dialog.treatment_label.text().endswith("[治疗末尾]") + assert "7.5%" in dialog.diagnosis_label.text() + assert "0.5g" in dialog.treatment_label.text() + + risk_labels = [ + label + for label in dialog.findChildren(QLabel) + if label.property("dialogAiRisk") + ] + assert len(risk_labels) == 6 + assert len({label.y() for label in risk_labels}) >= 2 + short_risk = risk_labels[0] + wrapped_risk = next(label for label in risk_labels if label.text() == long_risk) + assert short_risk.width() < dialog.risk_items.width() // 2 + assert wrapped_risk.width() <= 340 + assert wrapped_risk.height() > short_risk.height() + assert max(label.y() + label.height() for label in risk_labels) <= dialog.risk_items.height() + + dialog.close() + application.processEvents() + + +def test_reception_ai_card_is_compact_preview_without_nested_scroll( + application: QApplication, +) -> None: + payload = { + "diagnosis_advice": ["2型糖尿病,HbA1c 7.5%,需要继续监测。"] * 12, + "risk_assessment": [ + {"label": "低血糖", "level": "high"}, + {"label": "依从性风险", "level": "medium"}, + { + "label": "这是一个需要在紧凑卡片内自行换行而不能向右溢出的长风险项目。", + "level": "low", + }, + {"label": "复诊中断", "level": "medium"}, + {"label": "饮食波动", "level": "low"}, + {"label": "并发症筛查延误", "level": "high"}, + ], + "treatment_advice": ["二甲双胍 0.5g,每日2次。"] * 10, + "model_key": "qwen", + "model_label": "千问", + } + payload_before = deepcopy(payload) + page = ReceptionPage(object(), PermissionSet([])) + page._render_ai_analysis_payload(payload, "qwen") + page.ai_analysis_stack.setCurrentWidget(page.ai_analysis_content_page) + page.detail_stack.setCurrentIndex(1) + page.resize(1494, 832) + page.show() + application.processEvents() + + assert payload == payload_before + assert not isinstance(page.ai_analysis_content_page, QScrollArea) + assert page.ai_analysis_card.findChildren(QScrollArea) == [] + assert page.ai_analysis_card.minimumHeight() < 470 + assert page.ai_analysis_card.maximumHeight() > 520 + assert page.ai_analysis_card.sizeHint().height() < 470 + assert page.ai_summary_label.fullText() == _ai_narrative_text( + payload["diagnosis_advice"] + ) + assert page.ai_treatment_label.fullText() == _ai_narrative_text( + payload["treatment_advice"] + ) + assert page.ai_summary_label.text().count("\n") + 1 == 3 + assert page.ai_treatment_label.text().count("\n") + 1 == 2 + assert page.ai_summary_label.text().endswith("…") + assert page.ai_treatment_label.text().endswith("…") + + chips = [ + label + for label in page.ai_risk_chip_host.findChildren(QLabel) + if label.property("receptionRiskChip") + ] + overflow = [ + label + for label in page.ai_risk_chip_host.findChildren(QLabel) + if label.property("receptionRiskOverflow") + ] + assert len(chips) == 3 + assert [label.text() for label in overflow] == ["+3 项"] + assert max(label.x() + label.width() for label in [*chips, *overflow]) <= ( + page.ai_risk_chip_host.width() + ) + assert max(label.y() + label.height() for label in [*chips, *overflow]) <= ( + page.ai_risk_chip_host.height() + ) + assert page.ai_risk_label.text().count("、") == 5 + + page.close() + application.processEvents() diff --git a/app/tests/test_patients_ui.py b/app/tests/test_patients_ui.py index a5397e01c..21ef87ce3 100644 --- a/app/tests/test_patients_ui.py +++ b/app/tests/test_patients_ui.py @@ -621,17 +621,36 @@ def test_patient_list_reference_geometry_and_row_actions( repository = DemoDoctorRepository() session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD) page = PatientsPage(repository, permissions=session.permissions, current_user=session.user) - page.resize(1460, 820) + # 1366x768 shell minus its 170 px patient rail, 26 px outer gutter, + # and 62 px top bar leaves a 1170x680 page viewport. + page.resize(1170, 680) page.show() application.processEvents() page.patient_workspace.refresh() application.processEvents() workspace = page.patient_workspace + assert page.header.height() == 62 + assert workspace.filter_card.height() <= 92 assert all( - button.minimumHeight() == 56 and button.maximumHeight() == 56 + button.minimumHeight() == 44 and button.maximumHeight() == 44 for button in workspace.summary_buttons.values() ) + assert all( + widget.minimumWidth() == 0 and widget.maximumWidth() > 1000 + for widget in ( + workspace.keyword_edit, + workspace.status_host, + workspace.quick_host, + workspace.date_host, + ) + ) + assert page.tabs.minimumHeight() == 0 + assert workspace.content_stack.minimumHeight() == 0 + assert workspace.table.minimumHeight() == 0 + assert workspace.bottom_actions.isHidden() + assert workspace.table.viewport().height() // 40 >= 6 + assert workspace.pager.isVisibleTo(page) assert workspace.table.objectName() == "PatientTable" assert workspace.table.columnCount() == 10 assert workspace.table.horizontalHeaderItem(9).text() == "操作" diff --git a/app/tests/test_prescription_list_density.py b/app/tests/test_prescription_list_density.py new file mode 100644 index 000000000..91d663058 --- /dev/null +++ b/app/tests/test_prescription_list_density.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import os +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtCore import QPoint +from PySide6.QtGui import QImage +from PySide6.QtWidgets import QAbstractItemView, QApplication, QComboBox, QFrame, QWidget + +from doctor_workstation.ui.pages import prescription_library as library_module +from doctor_workstation.ui.pages import prescriptions as prescriptions_module +from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage +from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage +from doctor_workstation.ui.widgets import BusinessPager + + +@pytest.fixture(scope="module") +def application() -> QApplication: + return QApplication.instance() or QApplication([]) + + +@pytest.fixture(autouse=True) +def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None: + def run_immediately( + function: Any, + *args: Any, + on_success: Any = None, + on_error: Any = None, + on_finished: Any = None, + **kwargs: Any, + ) -> object: + try: + result = function(*args, **kwargs) + except Exception as error: + if on_error is not None: + on_error(error) + else: + if on_success is not None: + on_success(result) + finally: + if on_finished is not None: + on_finished() + return object() + + monkeypatch.setattr(prescriptions_module, "run_async", run_immediately) + monkeypatch.setattr(library_module, "run_async", run_immediately) + + +def _issued_row(index: int) -> dict[str, Any]: + return { + "id": 1000 + index, + "sn": f"CF-202608-{1000 + index}", + "prescription_type": "汤剂", + "is_system_auto": index % 2, + "patient_name": ("林晓岚", "周明远", "许安然")[index % 3], + "gender": 2 if index % 2 else 1, + "age": 29 + index, + "audit_status": index % 3, + "void_status": 0, + "has_prescription_order": index % 2, + "creator_id": 7, + "doctor_name": "陈医生", + "assistant_name": "赵医助", + "create_time": f"2026-08-{(index % 9) + 10:02d} 09:30:00", + "herbs": [{"name": "黄芪", "dosage": 15}], + } + + +def _library_row(index: int) -> dict[str, Any]: + return { + "id": 2000 + index, + "prescription_name": ("益气养阴方", "清热祛湿方", "滋阴调和方")[index % 3], + "formula_type": "主方" if index % 3 else "辅方", + "herbs": [ + {"name": "黄芪", "dosage": 15}, + {"name": "党参", "dosage": 12}, + ], + "efficacy": ("益气养阴", "清热祛湿", "滋阴补肾")[index % 3], + "is_public": index % 2, + "disable_edit": 0, + "creator_id": 7, + "creator_name": "陈医生", + "create_time": f"2026-08-{(index % 9) + 10:02d} 08:20:00", + } + + +class DensityRepository: + def __init__(self) -> None: + self.issued_rows = [_issued_row(index) for index in range(15)] + self.library_rows = [_library_row(index) for index in range(15)] + + def list_diagnosis_doctors(self) -> list[dict[str, Any]]: + return [{"id": 7, "name": "陈医生"}, {"id": 8, "name": "孙医生"}] + + def list_prescriptions(self, **_filters: Any) -> dict[str, Any]: + return {"lists": self.issued_rows, "count": 44} + + def list_prescription_templates(self, **_filters: Any) -> dict[str, Any]: + return {"lists": self.library_rows, "count": 41} + + +def _new_page(kind: str) -> PrescriptionsPage | PrescriptionLibraryPage: + repository = DensityRepository() + user = SimpleNamespace(id=7, name="陈医生", root=1, role_ids=[0]) + permissions = {"*"} + if kind == "issued": + page: PrescriptionsPage | PrescriptionLibraryPage = PrescriptionsPage( + repository, permissions, user + ) + else: + page = PrescriptionLibraryPage(repository, permissions, user) + page.refresh() + return page + + +def _settle(application: QApplication) -> None: + for _ in range(5): + application.processEvents() + + +def _fully_visible_rows(page: PrescriptionsPage | PrescriptionLibraryPage) -> int: + viewport = page.table.viewport() + return sum( + 1 + for row in range(page.table.rowCount()) + if ( + (item := page.table.item(row, 0)) is not None + and (rect := page.table.visualItemRect(item)).isValid() + and rect.top() >= 0 + and rect.bottom() < viewport.height() + ) + ) + + +def test_business_pager_is_shared_fixed_and_not_a_fake_dropdown( + application: QApplication, +) -> None: + pager = BusinessPager(15) + pager.update_state(2, 44) + pager.show() + _settle(application) + + assert prescriptions_module.BusinessPager is BusinessPager + assert 40 <= pager.height() <= 44 + assert pager.minimumHeight() == pager.maximumHeight() == 42 + assert pager.findChildren(QComboBox) == [] + assert pager.page_size_label.text() == "15 条/页" + margins = pager.layout().contentsMargins() + assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (16, 4, 16, 4) + assert pager.page_label is not None and pager.page_label.text() == "2" + pager.close() + + +@pytest.mark.parametrize("kind", ["issued", "library"]) +@pytest.mark.parametrize( + ("size", "minimum_visible_rows"), + [((1366, 768), 6), ((1710, 920), 9)], +) +def test_desktop_sizes_keep_rows_and_pager_visible_and_aligned( + application: QApplication, + kind: str, + size: tuple[int, int], + minimum_visible_rows: int, +) -> None: + page = _new_page(kind) + page.resize(*size) + page.show() + _settle(application) + + header = page.findChild(QWidget, "PageHeader") + toolbar_name = "PrescriptionToolbar" if kind == "issued" else "PrescriptionLibraryToolbar" + toolbar = page.findChild(QFrame, toolbar_name) + assert header is not None and 60 <= header.height() <= 64 + assert toolbar is not None and 44 <= toolbar.height() <= 48 + assert 40 <= page.pager.height() <= 44 + assert page.pager.minimumHeight() == page.pager.maximumHeight() + assert page.table.minimumHeight() == 0 + assert page.table.horizontalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel + assert page.table.verticalScrollMode() == QAbstractItemView.ScrollMode.ScrollPerPixel + assert _fully_visible_rows(page) >= minimum_visible_rows + + pager_position = page.pager.mapTo(page, QPoint()) + assert pager_position.x() >= 0 + assert pager_position.x() + page.pager.width() <= page.width() + assert pager_position.y() >= 0 + assert pager_position.y() + page.pager.height() <= page.height() + page_size_right = page.pager.page_size_label.mapTo(page, QPoint()).x() + ( + page.pager.page_size_label.width() + ) + assert page_size_right <= page.width() + pager_margins = page.pager.layout().contentsMargins() + toolbar_margins = toolbar.layout().contentsMargins() + assert pager_margins.left() == toolbar_margins.left() == 16 + assert pager_margins.right() == toolbar_margins.right() == 16 + + if kind == "issued": + filters = page.findChild(QFrame, "PrescriptionFilterBar") + assert filters is not None and 84 <= filters.height() <= 92 + else: + filters = page.findChild(QFrame, "PrescriptionLibraryFilterBar") + assert filters is not None + assert page.name_filter.minimumWidth() < 500 + filter_right = filters.contentsRect().right() + for control in ( + page.name_filter, + page.formula_filter, + page.visibility_filter, + page.effect_filter, + page.query_button, + page.reset_button, + ): + right = control.mapTo(filters, QPoint()).x() + control.width() + assert right <= filter_right + + page.close() + _settle(application) + + +def test_density_reference_artifacts_exist() -> None: + root = Path(__file__).resolve().parents[1] + expected = { + root / "artifacts" / "prescription_list_density" / "prescriptions_1366x768.png": ( + 1366, + 768, + ), + root / "artifacts" / "prescription_list_density" / "prescriptions_1710x920.png": ( + 1710, + 920, + ), + root + / "artifacts" + / "prescription_list_density" + / "prescription_library_1366x768.png": (1366, 768), + root + / "artifacts" + / "prescription_list_density" + / "prescription_library_1710x920.png": (1710, 920), + } + for path, dimensions in expected.items(): + image = QImage(str(path)) + assert not image.isNull(), path + assert (image.width(), image.height()) == dimensions diff --git a/app/tests/test_prescription_security_ui.py b/app/tests/test_prescription_security_ui.py index 3b256c98b..86d6ac55f 100644 --- a/app/tests/test_prescription_security_ui.py +++ b/app/tests/test_prescription_security_ui.py @@ -16,6 +16,7 @@ from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module from doctor_workstation.ui.dialogs import prescription as dialog_module from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog from doctor_workstation.ui.dialogs.prescription import ( + DiagnosisDetailDialog, PrescriptionEditorDialog, PrescriptionOrderDialog, PrescriptionTemplateDialog, @@ -287,6 +288,125 @@ def test_paid_order_response_is_bound_to_active_diagnosis_and_blocks_save( application.processEvents() +def test_diagnosis_order_detail_lookup_is_queued_before_repository_call( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + queued: list[tuple[Any, dict[str, Any]]] = [] + requested: list[int] = [] + shown: list[tuple[int, str]] = [] + + class Repository: + def get_prescription_order(self, order_id: int) -> dict[str, Any]: + requested.append(order_id) + return {"id": order_id, "order_no": f"DETAIL-{order_id}"} + + def queue_async(function: Any, **options: Any) -> object: + queued.append((function, options)) + return object() + + def present_order_detail( + _host: Any, + order: dict[str, Any], + *, + order_id: int, + permissions: Any, + exec_: bool, + ) -> None: + del permissions, exec_ + shown.append((order_id, order["order_no"])) + + monkeypatch.setattr(dialog_module, "run_async", queue_async) + monkeypatch.setattr(diagnosis_module, "present_order_detail", present_order_detail) + dialog = DiagnosisDetailDialog( + {"orders": [{"id": 17, "order_no": "ROW-17"}]}, + repository=Repository(), + ) + table = dialog._order_detail_table + button = dialog._order_detail_button + assert table is not None + assert button is not None + table.setCurrentCell(0, 0) + + button.click() + + assert len(queued) == 1 + assert requested == [] + assert shown == [] + assert not table.isEnabled() + assert not button.isEnabled() + + function, options = queued[0] + options["on_success"](function()) + options["on_finished"]() + assert requested == [17] + assert shown == [(17, "DETAIL-17")] + assert table.isEnabled() + assert button.isEnabled() + dialog.close() + application.processEvents() + + +def test_diagnosis_order_detail_ignores_stale_result_and_keeps_row_fallback( + application: QApplication, + monkeypatch: pytest.MonkeyPatch, +) -> None: + queued: list[dict[str, Any]] = [] + shown: list[tuple[int, str]] = [] + + def queue_async(_function: Any, **options: Any) -> object: + queued.append(options) + return object() + + def present_order_detail( + _host: Any, + order: dict[str, Any], + *, + order_id: int, + permissions: Any, + exec_: bool, + ) -> None: + del permissions, exec_ + shown.append((order_id, order["order_no"])) + + repository = SimpleNamespace(get_prescription_order=lambda order_id: {"id": order_id}) + monkeypatch.setattr(dialog_module, "run_async", queue_async) + monkeypatch.setattr(diagnosis_module, "present_order_detail", present_order_detail) + dialog = DiagnosisDetailDialog( + { + "orders": [ + {"id": 21, "order_no": "ROW-21"}, + {"id": 22, "order_no": "ROW-22"}, + ] + }, + repository=repository, + ) + table = dialog._order_detail_table + button = dialog._order_detail_button + assert table is not None + assert button is not None + + table.setCurrentCell(0, 0) + dialog._open_selected_order() + table.setCurrentCell(1, 0) + dialog._open_selected_order() + assert len(queued) == 2 + + queued[0]["on_success"]({"id": 21, "order_no": "STALE-21"}) + queued[0]["on_finished"]() + assert shown == [] + assert not table.isEnabled() + assert not button.isEnabled() + + queued[1]["on_error"](RuntimeError("detail unavailable")) + queued[1]["on_finished"]() + assert shown == [(22, "ROW-22")] + assert table.isEnabled() + assert button.isEnabled() + dialog.close() + application.processEvents() + + def _finish_queued(callback: dict[str, Any], result: Any) -> None: callback["on_success"](result) if callback.get("on_finished"): diff --git a/app/tests/test_reception_parity_ui.py b/app/tests/test_reception_parity_ui.py index 015efe98c..8f2dff64e 100644 --- a/app/tests/test_reception_parity_ui.py +++ b/app/tests/test_reception_parity_ui.py @@ -2,7 +2,7 @@ from __future__ import annotations import json import os -from datetime import date +from datetime import date, timedelta from pathlib import Path from typing import Any @@ -12,7 +12,7 @@ import httpx import pytest from PySide6.QtCore import QDate, QPoint, Qt from PySide6.QtGui import QPalette -from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QScrollArea +from PySide6.QtWidgets import QApplication, QLabel, QPushButton, QScrollArea, QWidget from doctor_workstation.core import PermissionSet from doctor_workstation.services.api_client import ApiClient @@ -283,9 +283,10 @@ def test_queue_uses_admin_same_day_contract( "status": 1, "start_date": date.today().isoformat(), "end_date": date.today().isoformat(), - "page_no": 1, - "page_size": 15, - "patient_name": "王小明", + "page_no": 1, + "page_size": 15, + "patient_name": "王小明", + "include_status_counts": 1, } ] @@ -296,6 +297,167 @@ def test_queue_uses_admin_same_day_contract( application.processEvents() +def test_reception_daily_records_use_backend_matrix_contract( + application: QApplication, + immediate_async: None, +) -> None: + today = date.today().isoformat() + tracking_calls: list[tuple[int, str, str]] = [] + note_calls: list[int] = [] + + class Repository: + fail_tracking = False + + def list_appointments(self, **_kwargs: Any) -> dict[str, Any]: + return { + "lists": [ + { + "id": 71, + "diagnosis_id": 271, + # Production appointment.patient_id is the diagnosis id. + "patient_id": 271, + "patient_name": "日常记录患者", + "status": 1, + "appointment_time": "09:30:00", + } + ], + "count": 1, + } + + def get_reception(self, appointment_id: int) -> dict[str, Any]: + assert appointment_id == 71 + return { + "appointment": { + "id": 71, + "patient_id": 271, + "patient_name": "日常记录患者", + "status": 1, + }, + "diagnosis": { + "id": 271, + "patient_id": 971, + "patient_name": "日常记录患者", + "age": 56, + }, + "tracking_notes": [ + {"note_date": today, "content": "内嵌备注降级数据"} + ], + } + + def get_tracking_window( + self, + diagnosis_id: int, + *, + start_date: str, + end_date: str, + ) -> dict[str, Any]: + tracking_calls.append((diagnosis_id, start_date, end_date)) + if self.fail_tracking: + raise RuntimeError("tracking unavailable") + return { + "diagnosis_id": diagnosis_id, + "start_date": start_date, + "end_date": end_date, + "blood_records": [ + { + "record_date": today, + "record_time": "08:30:00", + "fasting_blood_sugar": 9.6, + "postprandial_blood_sugar": 11.4, + "other_blood_sugar": 8.5, + "systolic_pressure": 141, + "diastolic_pressure": 90, + "western_medicine": "二甲双胍", + "insulin": "睡前 8U", + "source": 1, + } + ], + "diet_records": [ + { + "record_date": today, + "breakfast_foods": ["小米粥"], + "lunch_foods": ["杂粮饭"], + "dinner_foods": ["青菜"], + } + ], + "exercise_records": [ + { + "record_date": today, + "exercise_type": "快走", + "duration": 45, + "intensity_text": "中等", + } + ], + } + + def list_tracking_notes(self, diagnosis_id: int) -> list[dict[str, Any]]: + note_calls.append(diagnosis_id) + return [{"note_date": today, "content": "睡眠改善,继续随访"}] + + repository = Repository() + page = ReceptionPage(repository, PermissionSet([])) + page.refresh() + application.processEvents() + + assert [ + page.detail_tabs.tabText(index) for index in range(page.detail_tabs.count()) + ] == ["问诊信息", "检查报告", "用药记录", "日常记录", "随访记录", "健康数据"] + assert tracking_calls == [ + (271, (date.today() - timedelta(days=6)).isoformat(), today) + ] + assert note_calls == [271] + assert page._selection_context()[-1] == 971 + + matrix = page.daily_panel.matrix + assert matrix.objectName() == "ReceptionDailyRecordsTable" + assert matrix.rowCount() == 11 + assert matrix.columnCount() == 8 + assert matrix.horizontalHeaderItem(0).text() == "指标" + assert matrix.horizontalHeaderItem(1).text() == today[5:] + assert [matrix.item(row, 0).text() for row in range(11)] == [ + "空腹血糖", + "餐后2h血糖", + "其他血糖", + "血压", + "西药", + "胰岛素", + "早餐", + "午餐", + "晚餐", + "运动", + "跟踪备注", + ] + assert matrix.item(0, 1).text() == "9.6 · 自录 ↑" + assert matrix.item(0, 1).data(Qt.ItemDataRole.UserRole)["high"] is True + assert matrix.item(1, 1).text() == "11.4 · 自录 ↑" + assert matrix.item(2, 1).text() == "8.5 · 自录" + assert matrix.item(2, 1).data(Qt.ItemDataRole.UserRole)["high"] is False + assert matrix.item(3, 1).text() == "141/90 · 自录 ↑" + assert matrix.item(4, 1).text() == "二甲双胍" + assert matrix.item(5, 1).text() == "睡前 8U" + assert matrix.item(6, 1).text() == "已记录" + assert matrix.item(9, 1).text() == "45min" + assert matrix.item(10, 1).text() == "睡眠改善,继续随访" + assert "睡眠改善" in page.followup_text.text() + + page.daily_panel.range_buttons["30"].click() + assert tracking_calls[-1] == ( + 271, + (date.today() - timedelta(days=29)).isoformat(), + today, + ) + assert page.daily_panel.matrix.columnCount() == 31 + + preserved = page.daily_panel.matrix.item(0, 1).text() + repository.fail_tracking = True + page.daily_panel.refresh_button.click() + assert page.daily_panel.matrix.item(0, 1).text() == preserved + assert "已保留上次数据" in page.daily_panel.state.label.text() + + page.close() + application.processEvents() + + def test_queue_date_picker_filters_the_selected_day( application: QApplication, immediate_async: None, @@ -394,6 +556,69 @@ def test_silent_queue_polls_reuse_rows_and_do_not_restart_detail_or_ai( application.processEvents() +def test_silent_poll_replaces_only_the_queue_row_with_visible_changes( + application: QApplication, + immediate_async: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + original_queue_row = reception_module.QueueRow + constructed: list[int] = [] + + class CountingQueueRow(original_queue_row): + def __init__(self, record: Any, parent: QWidget | None = None) -> None: + constructed.append(int(record["id"])) + super().__init__(record, parent) + + monkeypatch.setattr(reception_module, "QueueRow", CountingQueueRow) + + class Repository: + calls = 0 + + def list_appointments(self, **_kwargs: Any) -> dict[str, Any]: + self.calls += 1 + rows = [ + { + "id": index, + "diagnosis_id": 200 + index, + "patient_id": 100 + index, + "patient_name": f"患者{index}", + "clinical_diagnosis": "消渴", + "status": 1, + } + for index in range(1, 4) + ] + if self.calls > 1: + rows[1]["clinical_diagnosis"] = "消渴 · 气阴两虚" + return {"lists": rows, "count": 3} + + def get_reception(self, appointment_id: int) -> dict[str, Any]: + return { + "appointment": {"id": appointment_id, "patient_id": 100 + appointment_id}, + "diagnosis": { + "id": 200 + appointment_id, + "patient_id": 100 + appointment_id, + }, + } + + page = ReceptionPage(Repository(), PermissionSet([])) + page.refresh() + items = [page.queue_list.item(index) for index in range(3)] + widgets = [page.queue_list.itemWidget(item) for item in items] + assert constructed == [1, 2, 3] + + page.refresh(silent=True) + + assert all(page.queue_list.item(index) is items[index] for index in range(3)) + assert page.queue_list.itemWidget(items[0]) is widgets[0] + assert page.queue_list.itemWidget(items[1]) is not widgets[1] + assert page.queue_list.itemWidget(items[2]) is widgets[2] + assert constructed == [1, 2, 3, 2] + changed_row = page.queue_list.itemWidget(items[1]) + assert changed_row.findChild(QLabel, "ReceptionQueueSubline").text() == "消渴 · 气阴两虚" + page.close() + application.processEvents() + + def test_timer_poll_does_not_supersede_an_in_flight_queue_request( application: QApplication, monkeypatch: pytest.MonkeyPatch, @@ -905,9 +1130,10 @@ def test_queue_worker_uses_frozen_widget_snapshot( "status": 1, "start_date": date.today().isoformat(), "end_date": date.today().isoformat(), - "page_no": 1, - "page_size": 15, - "patient_name": "甲患者", + "page_no": 1, + "page_size": 15, + "patient_name": "甲患者", + "include_status_counts": 1, } ] page.close() @@ -1094,7 +1320,10 @@ def test_reception_auto_loads_structured_ai_analysis_and_matches_reference_geome left = page.ai_analysis_card.geometry() right = page.ai_assistant_card.geometry() - assert 470 <= left.height() <= 520 + assert left.height() < 470 + assert page.ai_analysis_card.minimumHeight() == 0 + assert page.ai_analysis_card.maximumHeight() > 520 + assert page.ai_analysis_card.findChildren(QScrollArea) == [] assert left.height() == right.height() assert 0 <= right.left() - left.right() - 1 <= 2 assert abs(left.width() * 5 - right.width() * 4) <= 10 @@ -1464,7 +1693,6 @@ def test_ai_analysis_dialog_switches_complete_cached_payloads_without_requests( "qwen 风险项目 1", "qwen 风险项目 2", "qwen 风险项目 3", - "qwen 风险项目 4", ] calls_before_dialog = list(repository.analysis_calls) page.ai_analysis_expand_button.click() diff --git a/app/tests/test_repository_parity.py b/app/tests/test_repository_parity.py index 9d53061c8..a3620eba0 100644 --- a/app/tests/test_repository_parity.py +++ b/app/tests/test_repository_parity.py @@ -7,7 +7,7 @@ from typing import Any import pytest -from doctor_workstation.core.errors import ApiProtocolError +from doctor_workstation.core.errors import ApiBusinessError, ApiHttpError, ApiProtocolError from doctor_workstation.core.models import Appointment, Consultation, PageResult, Prescription from doctor_workstation.services.mock_repository import DemoDoctorRepository from doctor_workstation.services.repository import ( @@ -271,6 +271,51 @@ def test_remote_reception_is_forcibly_scoped_to_today() -> None: } +def test_remote_reception_daily_records_use_admin_endpoints_exactly() -> None: + client = RecordingClient() + repository = RemoteDoctorRepository(client) # type: ignore[arg-type] + + repository.list_appointments( + status=1, + start_date="2026-08-11", + end_date="2026-08-17", + include_status_counts=1, + page_no=1, + page_size=15, + ) + repository.get_reception(71) + repository.get_tracking_window( + 271, + start_date="2026-08-11", + end_date="2026-08-17", + ) + repository.list_tracking_notes(271) + + assert client.get_calls[-4:] == [ + ( + "doctor.appointment/lists", + { + "status": 1, + "start_date": "2026-08-11", + "end_date": "2026-08-17", + "include_status_counts": 1, + "page_no": 1, + "page_size": 15, + }, + ), + ("doctor.appointment/reception", {"id": 71}), + ( + "tcm.diagnosis/trackingWindow", + { + "id": 271, + "start_date": "2026-08-11", + "end_date": "2026-08-17", + }, + ), + ("tcm.diagnosis/trackingNotes", {"diagnosis_id": 271}), + ] + + def test_remote_new_contracts_use_exact_admin_endpoints_and_dtos() -> None: """Prescription, patient and diagnosis methods remain thin endpoint adapters.""" @@ -600,6 +645,56 @@ def test_remote_diagnosis_ai_assistant_uses_first_party_endpoint_only() -> None: assert client.timeouts == [105.0] +def test_remote_diagnosis_ai_stream_normalises_chunks_in_order() -> None: + class StreamingClient(RecordingClient): + def post_event_stream(self, endpoint: str, payload: dict[str, Any], **kwargs: Any): + assert endpoint == "tcm.diagnosis/aiAssistantStream" + assert payload == {"id": 501, "prompt": "请辨证", "task": "tcm_pattern"} + assert kwargs["timeout"] == 105.0 + yield {"event": "start", "data": {"model_key": "qwen"}} + yield {"event": "delta", "data": {"content": "肝郁"}} + yield {"event": "delta", "data": {"delta": "脾虚"}} + yield {"event": "done", "data": {"model_label": "千问"}} + + client = StreamingClient() + events = list( + RemoteDoctorRepository(client).stream_diagnosis_ai( + 501, + "请辨证", + task="tcm_pattern", + ) + ) + + assert [event["event"] for event in events] == ["start", "delta", "delta", "done"] + assert "".join(event.get("text", "") for event in events) == "肝郁脾虚" + assert client.post_calls == [] + + +def test_remote_diagnosis_ai_stream_falls_back_once_but_not_for_error_event() -> None: + class MissingStreamClient(RecordingClient): + def post_event_stream(self, *args: Any, **kwargs: Any): + raise ApiHttpError("missing", status_code=404) + + missing_client = MissingStreamClient() + events = list( + RemoteDoctorRepository(missing_client).stream_diagnosis_ai(501, "请分析") + ) + assert [event["event"] for event in events] == ["start", "delta", "done"] + assert events[1]["text"] == "服务端分析结果" + assert [call[0] for call in missing_client.post_calls] == [ + "tcm.diagnosis/aiAssistant" + ] + + class ErrorStreamClient(RecordingClient): + def post_event_stream(self, *args: Any, **kwargs: Any): + yield {"event": "error", "data": {"message": "模型繁忙"}} + + error_client = ErrorStreamClient() + with pytest.raises(ApiBusinessError, match="模型繁忙"): + list(RemoteDoctorRepository(error_client).stream_diagnosis_ai(501, "请分析")) + assert error_client.post_calls == [] + + def test_remote_diagnosis_ai_analysis_uses_exact_post_contract() -> None: """The legacy default is qwen, followed by an explicit OpenAI request.""" diff --git a/app/tests/test_shell_contract.py b/app/tests/test_shell_contract.py index 8efd4cf5e..7b1b8e6f4 100644 --- a/app/tests/test_shell_contract.py +++ b/app/tests/test_shell_contract.py @@ -26,10 +26,16 @@ class _ShellPageDouble(QWidget): self.permissions = permissions self.current_user = current_user self.refresh_count = 0 + self.show_count = 0 def refresh(self) -> None: self.refresh_count += 1 + def showEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual + super().showEvent(event) + self.show_count += 1 + self.refresh() + @pytest.fixture(scope="module") def application() -> QApplication: @@ -211,6 +217,34 @@ def test_every_visible_page_navigates_and_visited_tabs_track_active_page( ] +def test_real_navigation_refreshes_once_and_current_page_click_is_a_noop( + application: QApplication, + shell_window: ShellWindow, +) -> None: + appointments = shell_window.pages["appointments"] + reception = shell_window.pages["reception"] + assert isinstance(appointments, _ShellPageDouble) + assert isinstance(reception, _ShellPageDouble) + assert appointments.refresh_count == 1 + assert appointments.show_count == 1 + assert reception.refresh_count == 0 + + shell_window.nav_buttons["reception"].click() + application.processEvents() + assert reception.refresh_count == 1 + assert reception.show_count == 1 + + shell_window.nav_buttons["reception"].click() + application.processEvents() + assert reception.refresh_count == 1 + assert reception.show_count == 1 + + shell_window.nav_buttons["appointments"].click() + application.processEvents() + assert appointments.refresh_count == 2 + assert appointments.show_count == 2 + + def test_non_fixed_tabs_close_and_active_close_renavigates( shell_window: ShellWindow, ) -> None: diff --git a/server/app/adminapi/controller/doctor/AppointmentController.php b/server/app/adminapi/controller/doctor/AppointmentController.php index 94bffa8a6..dab9a9c8d 100755 --- a/server/app/adminapi/controller/doctor/AppointmentController.php +++ b/server/app/adminapi/controller/doctor/AppointmentController.php @@ -4,9 +4,10 @@ namespace app\adminapi\controller\doctor; use app\adminapi\controller\BaseAdminController; use app\adminapi\lists\doctor\AppointmentLists; -use app\adminapi\logic\doctor\AppointmentLogic; -use app\adminapi\logic\doctor\DoctorNoteLogic; -use app\adminapi\validate\doctor\AppointmentValidate; +use app\adminapi\logic\doctor\AppointmentLogic; +use app\adminapi\logic\doctor\DoctorNoteLogic; +use app\adminapi\logic\tcm\DiagnosisLogic; +use app\adminapi\validate\doctor\AppointmentValidate; /** * 医生预约控制器 @@ -147,8 +148,11 @@ class AppointmentController extends BaseAdminController public function reception() { $params = (new AppointmentValidate())->goCheck('reception'); - $result = AppointmentLogic::reception($params); - return $this->data($result); + $result = AppointmentLogic::reception($params, $this->adminId, $this->adminInfo); + if (empty($result)) { + return $this->fail('预约记录不存在或无权访问'); + } + return $this->data($result); } /** @@ -165,10 +169,17 @@ class AppointmentController extends BaseAdminController return $this->success('通知已发送'); } - public function addDoctorNote() - { - $params = (new AppointmentValidate())->post()->goCheck('addDoctorNote'); - $params['doctor_id'] = $this->adminId; + public function addDoctorNote() + { + $params = (new AppointmentValidate())->post()->goCheck('addDoctorNote'); + if (!DiagnosisLogic::canViewReadonlyDiagnosis( + (int) $params['diagnosis_id'], + $this->adminId, + $this->adminInfo + )) { + return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问'); + } + $params['doctor_id'] = $this->adminId; $result = DoctorNoteLogic::addOrAppend($params); if ($result === false) { return $this->fail(DoctorNoteLogic::getError()); @@ -176,10 +187,17 @@ class AppointmentController extends BaseAdminController return $this->success('保存成功'); } - public function doctorNotes() - { - $params = (new AppointmentValidate())->goCheck('doctorNotes'); - return $this->data(DoctorNoteLogic::getByDiagnosis((int) $params['diagnosis_id'])); + public function doctorNotes() + { + $params = (new AppointmentValidate())->goCheck('doctorNotes'); + if (!DiagnosisLogic::canViewReadonlyDiagnosis( + (int) $params['diagnosis_id'], + $this->adminId, + $this->adminInfo + )) { + return $this->fail('诊单不存在或无权访问'); + } + return $this->data(DoctorNoteLogic::getByDiagnosis((int) $params['diagnosis_id'])); } public function deleteDoctorNoteImage() diff --git a/server/app/adminapi/controller/tcm/DiagnosisController.php b/server/app/adminapi/controller/tcm/DiagnosisController.php index 2d72bfab0..4015497ce 100755 --- a/server/app/adminapi/controller/tcm/DiagnosisController.php +++ b/server/app/adminapi/controller/tcm/DiagnosisController.php @@ -21,7 +21,8 @@ use app\adminapi\logic\tcm\DiagnosisAiLogic; use app\adminapi\logic\tcm\DiagnosisLogic; use app\adminapi\logic\tcm\PatientAiReportLogic; use app\adminapi\logic\tcm\TrackingNoteLogic; -use app\adminapi\validate\tcm\DiagnosisValidate; +use app\adminapi\service\AssistantSseProtocol; +use app\adminapi\validate\tcm\DiagnosisValidate; use app\common\model\Order; use app\common\model\WechatChatRecord; @@ -168,10 +169,13 @@ class DiagnosisController extends BaseAdminController * * @return \think\response\Json */ - public function trackingWindow() - { - $params = (new DiagnosisValidate())->goCheck('trackingWindow'); - $result = DiagnosisLogic::fetchTrackingWindow( + public function trackingWindow() + { + $params = (new DiagnosisValidate())->goCheck('trackingWindow'); + if (!DiagnosisLogic::canViewReadonlyDiagnosis((int) $params['id'], $this->adminId, $this->adminInfo)) { + return $this->fail(DiagnosisLogic::getError() ?: '诊单不存在或无权访问'); + } + $result = DiagnosisLogic::fetchTrackingWindow( (int) $params['id'], (string) ($params['start_date'] ?? ''), (string) ($params['end_date'] ?? '') @@ -865,9 +869,9 @@ class DiagnosisController extends BaseAdminController /** * @notes 基于当前授权诊单向 AI 助手提问,不接收客户端上游配置 */ - public function aiAssistant() - { - $params = (new DiagnosisValidate())->post()->goCheck('aiAssistant'); + public function aiAssistant() + { + $params = (new DiagnosisValidate())->post()->goCheck('aiAssistant'); $result = DiagnosisAiLogic::assistant( (int) $params['id'], (string) $params['task'], @@ -877,11 +881,113 @@ class DiagnosisController extends BaseAdminController ); if ($result === null) { return $this->fail(DiagnosisAiLogic::getError()); - } - return $this->data($result); - } - - /** + } + return $this->data($result); + } + + /** + * @notes 基于当前授权诊单向 AI 助手提问(SSE 真流式) + * + * 路由:POST tcm.diagnosis/aiAssistantStream body: id, task, prompt + */ + public function aiAssistantStream() + { + // 登录由全局中间件完成;请求校验、旧助手权限与 DataScope 必须全部 + // 在任何 SSE header / start 事件之前完成,失败时仍返回标准 JSON。 + $params = (new DiagnosisValidate())->post()->goCheck('aiAssistant'); + $prepared = DiagnosisAiLogic::prepareAssistant( + (int) $params['id'], + (string) $params['task'], + (string) ($params['prompt'] ?? ''), + $this->adminId, + $this->adminInfo + ); + if ($prepared === null) { + return $this->fail(DiagnosisAiLogic::getError()); + } + + $this->runAssistantSse($prepared); + } + + /** @param array $prepared */ + private function runAssistantSse(array $prepared): void + { + while (ob_get_level() > 0) { + ob_end_clean(); + } + + @ini_set('output_buffering', 'off'); + @ini_set('zlib.output_compression', '0'); + ignore_user_abort(true); + if (function_exists('apache_setenv')) { + @apache_setenv('no-gzip', '1'); + } + + header('Content-Type: text/event-stream; charset=utf-8'); + header('Cache-Control: no-cache, no-transform'); + header('Connection: keep-alive'); + header('X-Accel-Buffering: no'); + header('Content-Encoding: none'); + + echo ':' . str_repeat(' ', 2048) . "\n\n"; + $this->flushSseOutput(); + + $protocol = new AssistantSseProtocol(); + $emit = function (string $event, array $payload) use ($protocol): bool { + if ($protocol->isTerminal() || connection_aborted()) { + return false; + } + $encoded = $protocol->encode($event, $payload); + if ($encoded === null) { + return false; + } + echo $encoded; + $this->flushSseOutput(); + return !connection_aborted(); + }; + + $emit('start', [ + 'task' => (string) ($prepared['task'] ?? ''), + 'model_key' => (string) ($prepared['profile'] ?? ''), + 'message' => '已连接,正在生成…', + ]); + + try { + $result = DiagnosisAiLogic::streamPreparedAssistant( + $prepared, + static fn (string $delta): bool => $emit('delta', ['text' => $delta]), + static fn (): bool => connection_aborted() === 1 + ); + if (connection_aborted()) { + exit; + } + if ($result === null) { + $emit('error', [ + 'code' => 'AI_ASSISTANT_FAILED', + 'message' => 'AI 助手暂时不可用,请稍后重试', + ]); + } else { + $emit('done', $result); + } + } catch (\Throwable $e) { + $emit('error', [ + 'code' => 'AI_ASSISTANT_FAILED', + 'message' => 'AI 助手暂时不可用,请稍后重试', + ]); + } + + exit; + } + + private function flushSseOutput(): void + { + if (function_exists('ob_flush')) { + @ob_flush(); + } + flush(); + } + + /** * @notes 对当前授权诊单生成一次结构化 AI 智能分析,仅接受 qwen/openai 模型键 */ public function aiAnalysis() diff --git a/server/app/adminapi/controller/tcm/PrescriptionController.php b/server/app/adminapi/controller/tcm/PrescriptionController.php index d98114b0f..4228e78f4 100755 --- a/server/app/adminapi/controller/tcm/PrescriptionController.php +++ b/server/app/adminapi/controller/tcm/PrescriptionController.php @@ -137,9 +137,12 @@ class PrescriptionController extends BaseAdminController $diagnosisId = (int)($this->request->get('diagnosis_id') ?? 0); if (!$diagnosisId) { return $this->fail('诊单ID不能为空'); - } - $list = PrescriptionLogic::listByDiagnosis($diagnosisId); - return $this->data($list); + } + $list = PrescriptionLogic::listByDiagnosis($diagnosisId, (int) $this->adminId, $this->adminInfo); + if (PrescriptionLogic::getError() !== '') { + return $this->fail(PrescriptionLogic::getError()); + } + return $this->data($list); } /** diff --git a/server/app/adminapi/http/middleware/AuthMiddleware.php b/server/app/adminapi/http/middleware/AuthMiddleware.php index d01885378..71e5e0086 100755 --- a/server/app/adminapi/http/middleware/AuthMiddleware.php +++ b/server/app/adminapi/http/middleware/AuthMiddleware.php @@ -74,11 +74,13 @@ class AuthMiddleware // 全部路由 $allUri = $this->formatUrl($adminAuthCache->getAllUri()); - // 判断该当前访问的uri是否存在,不存在无需验证 - if (!in_array($accessUri, $allUri, true) - && !PharmacyUploadPermissionAlias::allows($accessUri, $allUri)) { - return $next($request); - } + // 判断该当前访问的uri是否存在,不存在无需验证 + if (!in_array($accessUri, $allUri, true) + && !PharmacyUploadPermissionAlias::allows($accessUri, $allUri) + && !($accessUri === 'tcm.diagnosis/aiassistantstream' + && in_array('tcm.diagnosis/aiassistant', $allUri, true))) { + return $next($request); + } // 当前管理员拥有的路由权限 $AdminUris = $adminAuthCache->getAdminUri() ?? []; @@ -109,9 +111,16 @@ class AuthMiddleware * 日常记录权限域:前端统一收口到 tcm.diagnosis/dailyRecord, * 但待办/跟踪备注接口仍保留历史路由名,故在鉴权层做精确别名映射。 */ - private function matchPermissionAlias(string $accessUri, array $adminUris): bool - { - if (PharmacyUploadPermissionAlias::isControlled($accessUri)) { + private function matchPermissionAlias(string $accessUri, array $adminUris): bool + { + // AI 助手流式端点与旧 blocking 端点共享同一权限;别名同时用于 + // allUri 判定和当前管理员权限判定,确保在 SSE headers 前完成鉴权。 + if ($accessUri === 'tcm.diagnosis/aiassistantstream' + && in_array('tcm.diagnosis/aiassistant', $adminUris, true)) { + return true; + } + + if (PharmacyUploadPermissionAlias::isControlled($accessUri)) { return PharmacyUploadPermissionAlias::allows($accessUri, $adminUris); } diff --git a/server/app/adminapi/lists/doctor/AppointmentLists.php b/server/app/adminapi/lists/doctor/AppointmentLists.php index 4b485e770..d6f10cd84 100755 --- a/server/app/adminapi/lists/doctor/AppointmentLists.php +++ b/server/app/adminapi/lists/doctor/AppointmentLists.php @@ -215,7 +215,7 @@ class AppointmentLists extends BaseAdminDataLists implements ListsSearchInterfac ->leftJoin('tcm_diagnosis u', 'a.patient_id = u.id') ->leftJoin('admin ad', 'a.doctor_id = ad.id') ->leftJoin('admin asst', 'u.assistant_id = asst.id') - ->field('a.*, u.patient_name as patient_name, u.phone as patient_phone, u.gender as gender, u.age as age, u.weight as weight, u.height as height, u.assistant_id as assistant_id, ad.name as doctor_name, asst.name as assistant_name, u.id as diagnosis_id, a.assistant_id as appointment_assistant_id'); + ->field('a.*, u.patient_id AS source_patient_id, u.patient_name as patient_name, u.phone as patient_phone, u.gender as gender, u.age as age, u.weight as weight, u.height as height, u.assistant_id as assistant_id, ad.name as doctor_name, asst.name as assistant_name, u.id as diagnosis_id, a.assistant_id as appointment_assistant_id'); if ($this->searchWhere !== []) { $query->where($this->searchWhere); } diff --git a/server/app/adminapi/logic/doctor/AppointmentLogic.php b/server/app/adminapi/logic/doctor/AppointmentLogic.php index fa451b572..429f4ca11 100755 --- a/server/app/adminapi/logic/doctor/AppointmentLogic.php +++ b/server/app/adminapi/logic/doctor/AppointmentLogic.php @@ -627,10 +627,35 @@ class AppointmentLogic extends BaseLogic * @param array $params * @return array */ - public static function reception(array $params): array - { - // 1) 挂号详情(已包含 patient_name / patient_phone / doctor_name / status_desc 等) - $appointment = self::detail($params); + public static function reception(array $params, int $adminId, array $adminInfo): array + { + self::$error = ''; + $appointmentId = (int) ($params['id'] ?? 0); + $appointmentRow = $appointmentId > 0 + ? Appointment::where('id', $appointmentId)->field(['id', 'patient_id', 'doctor_id'])->find() + : null; + if (!$appointmentRow) { + self::setError('预约记录不存在或无权访问'); + + return []; + } + $diagnosisRow = Diagnosis::where('id', (int) $appointmentRow->patient_id) + ->whereNull('delete_time') + ->field(['id', 'assistant_id']) + ->find(); + if (!self::appointmentRowManageableByAdmin( + $appointmentRow, + $diagnosisRow ?: null, + $adminId, + $adminInfo + )) { + self::setError('预约记录不存在或无权访问'); + + return []; + } + + // 1) 挂号详情(已包含 patient_name / patient_phone / doctor_name / status_desc 等) + $appointment = self::detail($params); if (empty($appointment)) { return []; } @@ -883,40 +908,62 @@ class AppointmentLogic extends BaseLogic /** * 与 AppointmentLists 一致的可见性(不含 progress_board / diag_scope_relax) */ - private static function appointmentRowManageableByAdmin( - Appointment $appointment, - ?Diagnosis $diag, - int $adminId, - array $adminInfo - ): bool { - $roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id')); - - if (in_array(1, $roleIds, true) && (int) $appointment->doctor_id !== $adminId) { - return false; - } - if (in_array(2, $roleIds, true)) { - $asst = $diag ? (int) $diag->assistant_id : 0; - if ($asst !== $adminId) { - return false; - } - } - - if (!DataScopeService::isEnabled()) { - return true; - } - $ids = DataScopeService::getVisibleAdminIds($adminId, $adminInfo); - if ($ids === []) { - return false; - } - if ($ids === null) { - return true; - } - $docId = (int) $appointment->doctor_id; - $asstId = $diag ? (int) $diag->assistant_id : 0; - - return in_array($docId, $ids, true) - || ($asstId > 0 && in_array($asstId, $ids, true)); - } + private static function appointmentRowManageableByAdmin( + Appointment $appointment, + ?Diagnosis $diag, + int $adminId, + array $adminInfo + ): bool { + $docId = (int) $appointment->doctor_id; + $asstId = $diag ? (int) $diag->assistant_id : 0; + $isRoot = !empty($adminInfo['root']) && (int) $adminInfo['root'] === 1; + $roleIds = $isRoot + ? [] + : array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id')); + $visibleIds = null; + if (!$isRoot && DataScopeService::isEnabled()) { + $visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo); + } + + return self::appointmentRowManageableForScope( + $docId, + $asstId, + $adminId, + $roleIds, + $visibleIds, + $isRoot + ); + } + + /** + * @param array $roleIds + * @param array|null $visibleIds null 表示未启用数据范围或全量可见 + */ + private static function appointmentRowManageableForScope( + int $doctorId, + int $assistantId, + int $adminId, + array $roleIds, + ?array $visibleIds, + bool $isRoot + ): bool { + if ($isRoot) { + return true; + } + if (in_array(1, $roleIds, true) && $doctorId !== $adminId) { + return false; + } + if (in_array(2, $roleIds, true) && $assistantId !== $adminId) { + return false; + } + if ($visibleIds === []) { + return false; + } + + return $visibleIds === null + || in_array($doctorId, $visibleIds, true) + || ($assistantId > 0 && in_array($assistantId, $visibleIds, true)); + } /** * 后台编辑挂号(预约日期/时段/类型/状态/备注/医助) diff --git a/server/app/adminapi/logic/doctor/DoctorNoteLogic.php b/server/app/adminapi/logic/doctor/DoctorNoteLogic.php index 6f52694e0..3c7a2720b 100755 --- a/server/app/adminapi/logic/doctor/DoctorNoteLogic.php +++ b/server/app/adminapi/logic/doctor/DoctorNoteLogic.php @@ -25,8 +25,8 @@ class DoctorNoteLogic extends BaseLogic ->find(); $newContent = trim($params['content'] ?? ''); - $newImages = array_map([self::class, 'toRelativePath'], self::parseJsonArray($params['tongue_images'] ?? [])); - $newReports = array_map([self::class, 'toRelativePath'], self::parseJsonArray($params['report_files'] ?? [])); + $newImages = self::normalizeNewAttachmentPaths($params['tongue_images'] ?? []); + $newReports = self::normalizeNewAttachmentPaths($params['report_files'] ?? []); if ($existing) { $data = []; @@ -169,15 +169,51 @@ class DoctorNoteLogic extends BaseLogic */ private static function toRelativePath(string $url): string { - if (empty($url)) return $url; - if (stripos($url, 'http://') !== 0 && stripos($url, 'https://') !== 0) { + $url = trim($url); + if ($url === '') return $url; + + $urlParts = parse_url($url); + if (!is_array($urlParts) || empty($urlParts['scheme'])) { return $url; } + + $scheme = strtolower((string) $urlParts['scheme']); + if (!in_array($scheme, ['http', 'https'], true)) { + return $url; + } + // 获取当前存储域名 - $domain = self::getStorageDomain(); - if ($domain && stripos($url, rtrim($domain, '/')) === 0) { - $relative = substr($url, strlen(rtrim($domain, '/'))); - return ltrim($relative, '/'); + $domain = rtrim(self::getStorageDomain(), '/'); + $domainParts = $domain !== '' ? parse_url($domain) : false; + if (is_array($domainParts)) { + $domainScheme = strtolower((string) ($domainParts['scheme'] ?? '')); + $urlHost = strtolower(rtrim((string) ($urlParts['host'] ?? ''), '.')); + $domainHost = strtolower(rtrim((string) ($domainParts['host'] ?? ''), '.')); + $urlPort = (int) ($urlParts['port'] ?? ($scheme === 'https' ? 443 : 80)); + $domainPort = (int) ( + $domainParts['port'] ?? ($domainScheme === 'https' ? 443 : 80) + ); + $urlPath = (string) ($urlParts['path'] ?? ''); + $domainPath = rtrim((string) ($domainParts['path'] ?? ''), '/'); + $pathInsideDomain = $domainPath === '' + || $urlPath === $domainPath + || str_starts_with($urlPath, $domainPath . '/'); + + if ( + $domainScheme === $scheme + && $domainHost !== '' + && $domainHost === $urlHost + && $domainPort === $urlPort + && $pathInsideDomain + ) { + $relative = $domainPath === '' + ? $urlPath + : substr($urlPath, strlen($domainPath)); + if (isset($urlParts['query']) && $urlParts['query'] !== '') { + $relative .= '?' . $urlParts['query']; + } + return ltrim($relative, '/'); + } } // 非当前存储域名,保留完整 URL return $url; @@ -193,6 +229,36 @@ class DoctorNoteLogic extends BaseLogic return $storage ? ($storage['domain'] ?? '') : ''; } + /** + * 备注附件只接受站内相对路径或当前存储域已上传的 URL。 + * 存储域 URL 先转为相对路径,避免将任意外部 URL 持久化到病例页。 + * + * @param mixed $value + * @return array + */ + private static function normalizeNewAttachmentPaths($value): array + { + $paths = []; + foreach (self::parseJsonArray($value) as $rawPath) { + $path = trim((string) $rawPath); + if ($path === '') { + continue; + } + $path = self::toRelativePath($path); + $scheme = parse_url($path, PHP_URL_SCHEME); + if ( + (is_string($scheme) && $scheme !== '') + || str_starts_with($path, '//') + || str_contains($path, "\0") + ) { + throw new \InvalidArgumentException('备注附件必须来自当前文件存储域'); + } + $paths[] = $path; + } + + return array_values(array_unique($paths)); + } + private static function parseJsonArray($value): array { if (is_array($value)) return $value; diff --git a/server/app/adminapi/logic/firstvisit/FirstVisitConversionLogic.php b/server/app/adminapi/logic/firstvisit/FirstVisitConversionLogic.php index 55ff4933b..80d0fab5b 100644 --- a/server/app/adminapi/logic/firstvisit/FirstVisitConversionLogic.php +++ b/server/app/adminapi/logic/firstvisit/FirstVisitConversionLogic.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace app\adminapi\logic\firstvisit; +use app\adminapi\logic\auth\AuthLogic; use app\adminapi\logic\dept\DeptLogic; use app\adminapi\logic\stats\ConversionLogic; use app\adminapi\logic\stats\YejiStatsLogic; @@ -23,6 +24,9 @@ use think\facade\Db; class FirstVisitConversionLogic { private const ASSISTANT_ROLE_ID = 2; + private const FINANCE_PERMISSION = 'firstvisit.conversion/viewFinance'; + private const FINANCE_ALWAYS_ROLE_NAMES = ['经理', '管理员', '系统管理员']; + private const FINANCE_FIELD_KEYS = ['account_cost', 'cash_cost', 'roi']; /** @return array */ public static function overview(array $params, int $adminId, array $adminInfo): array @@ -64,11 +68,11 @@ class FirstVisitConversionLogic $effectiveAdminIds = self::intersectVisibleIds($effectiveAdminIds, $deptAdminIds); } - $selectedAssistantValid = $selectedAssistantId <= 0; - if ($selectedAssistantId > 0) { - $selectedAssistantValid = self::isActiveAssistant($selectedAssistantId) - && ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true)); - $effectiveAdminIds = $selectedAssistantValid ? [$selectedAssistantId] : []; + $selectedAssistantValid = $selectedAssistantId <= 0; + if ($selectedAssistantId > 0) { + $selectedAssistantValid = self::isActiveAssistant($selectedAssistantId) + && ($effectiveAdminIds === null || in_array($selectedAssistantId, $effectiveAdminIds, true)); + $effectiveAdminIds = $selectedAssistantValid ? [$selectedAssistantId] : []; } $costAllocationAdminIds = self::costAllocationAdminIds( $effectiveAdminIds, @@ -137,23 +141,36 @@ class FirstVisitConversionLogic (int) $summary['total_open_count'] ); - $rankingKind = self::rankingKind($scopeValue, $selectedAssistantId); - $rankingRows = self::rankingRows($rows, $rankingKind); + $rankingKind = self::rankingKind($scopeValue, $selectedAssistantId); + $rankingRows = self::rankingRows($rows, $rankingKind); // 目前只维护了部门月度目标;本人范围或筛选单个员工时不能拿整个部门目标冒充个人目标。 $targetDeptIds = ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) ? [] : self::resolveTargetDeptIds($allowedDeptSet, $selectedDeptIds, $selectedDeptId); $target = self::buildTargetProgress((int) date('Y'), $effectiveAdminIds, $targetDeptIds); - $selectedDeptName = $selectedDeptId > 0 && $deptSelectionValid - ? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '') - : ''; - $selectedAssistantName = $selectedAssistantId > 0 && $selectedAssistantValid - ? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '') - : ''; + $selectedDeptName = $selectedDeptId > 0 && $deptSelectionValid + ? (string) (Db::name('dept')->where('id', $selectedDeptId)->whereNull('delete_time')->value('name') ?? '') + : ''; + $selectedAssistantName = $selectedAssistantId > 0 && $selectedAssistantValid + ? (string) (Admin::where('id', $selectedAssistantId)->whereNull('delete_time')->value('name') ?? '') + : ''; $selectedMediaChannelName = $selectedMediaChannelCode !== '' ? (string) ($selectedMediaChannel['channel_name'] ?? $selectedMediaChannelCode) : ''; + if ($selectedMediaChannelName !== '' && !empty($selectedMediaChannel['is_group'])) { + $selectedMediaChannelName .= '(全部)'; + } + $canViewFinance = self::canViewFinance($adminId, $adminInfo); + if (!$canViewFinance) { + $summary = self::maskFinanceFields($summary); + foreach ($rows as &$row) { + if (is_array($row)) { + $row = self::maskFinanceFields($row); + } + } + unset($row); + } return [ 'meta' => [ 'time_type' => $timeType, @@ -161,9 +178,9 @@ class FirstVisitConversionLogic 'start_date' => $startDate, 'end_date' => $endDate, 'generated_at' => date('Y-m-d H:i:s'), - 'scope_value' => $scopeValue, - 'scope_label' => DataScopeService::scopeLabel($scopeValue), - 'ranking_kind' => $rankingKind, + 'scope_value' => $scopeValue, + 'scope_label' => DataScopeService::scopeLabel($scopeValue), + 'ranking_kind' => $rankingKind, 'selected_dept_name' => $selectedDeptName, 'selected_assistant_name' => $selectedAssistantName, 'selected_media_channel_code' => $selectedMediaChannelCode, @@ -171,6 +188,7 @@ class FirstVisitConversionLogic 'open_count_source' => $selectedMediaChannelCode === '' ? '个人业绩录入' : '个人业绩录入(按渠道名称匹配)', + 'can_view_finance' => $canViewFinance, 'appointment_rule' => '按预约日期统计,归属优先挂号医助、再回退诊单医助;仅含已预约、已完成和已过号', 'registration_rule' => '按支付时间统计已支付且实收金额大于 0、低于 10 元的订单,每笔计 1 个挂号并按订单创建人归属', 'performance_rule' => '按业务订单创建时间和创建人统计,排除取消、拒收、退款及已发生退款的订单', @@ -520,45 +538,101 @@ class FirstVisitConversionLogic return []; } + $values = [ + $channelCode, + $channel['channel_name'] ?? '', + $channel['source_tag_name'] ?? '', + $channel['legacy_channel_name'] ?? '', + $channel['legacy_source_tag_name'] ?? '', + ]; + foreach (['channel_codes', 'channel_names', 'source_tag_names'] as $listKey) { + if (!isset($channel[$listKey]) || !is_array($channel[$listKey])) { + continue; + } + foreach ($channel[$listKey] as $item) { + $values[] = $item; + } + } + return array_values(array_unique(array_filter(array_map( static fn ($value): string => trim((string) $value), - [ - $channelCode, - $channel['channel_name'] ?? '', - $channel['source_tag_name'] ?? '', - $channel['legacy_channel_name'] ?? '', - $channel['legacy_source_tag_name'] ?? '', - ] - ), static fn (string $value): bool => $value !== ''))); + $values + ), static fn (string $value): bool => $value !== '' && !str_starts_with($value, MediaChannelService::GROUP_CODE_PREFIX)))); } - /** 根据生效数据范围返回排行榜展示维度,不能把 scope_value 当作角色枚举。 */ - private static function rankingKind(int $scopeValue, int $selectedAssistantId = 0): string - { - if ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) { - return 'hidden'; - } - - return $scopeValue === DataScopeService::SCOPE_DEPT ? 'member' : 'group'; - } - - /** @param array> $rows @return array> */ - private static function rankingRows(array $rows, string $rankingKind): array - { - if ($rankingKind === 'hidden') { - return []; - } - - // “仅本部门”范围使用可见成员维度;更大范围使用当前可见组织根节点的 - // 直属下级,避免父子汇总同时参与占比。 - if ($rankingKind === 'member') { - $members = []; - self::collectRankingMembers($rows, $members); - - return array_values($members); - } - - // lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量 + private static function canViewFinance(int $adminId, array $adminInfo): bool + { + if ((int) ($adminInfo['root'] ?? 0) === 1) { + return true; + } + foreach (self::roleNamesFromAdminInfo($adminInfo) as $roleName) { + if (in_array($roleName, self::FINANCE_ALWAYS_ROLE_NAMES, true)) { + return true; + } + } + if ($adminId <= 0) { + return false; + } + + return in_array(self::FINANCE_PERMISSION, AuthLogic::getAuthByAdminId($adminId), true); + } + + /** @return string[] */ + private static function roleNamesFromAdminInfo(array $adminInfo): array + { + $names = preg_split('/[\/,,、]/u', (string) ($adminInfo['role_name'] ?? '')) ?: []; + + return array_values(array_filter(array_map('trim', $names), static fn (string $name): bool => $name !== '')); + } + + /** + * @param array $entity + * @return array + */ + private static function maskFinanceFields(array $entity): array + { + foreach (self::FINANCE_FIELD_KEYS as $key) { + unset($entity[$key]); + } + if (isset($entity['children']) && is_array($entity['children'])) { + foreach ($entity['children'] as &$child) { + if (is_array($child)) { + $child = self::maskFinanceFields($child); + } + } + unset($child); + } + + return $entity; + } + + /** 根据生效数据范围返回排行榜展示维度,不能把 scope_value 当作角色枚举。 */ + private static function rankingKind(int $scopeValue, int $selectedAssistantId = 0): string + { + if ($scopeValue === DataScopeService::SCOPE_SELF || $selectedAssistantId > 0) { + return 'hidden'; + } + + return $scopeValue === DataScopeService::SCOPE_DEPT ? 'member' : 'group'; + } + + /** @param array> $rows @return array> */ + private static function rankingRows(array $rows, string $rankingKind): array + { + if ($rankingKind === 'hidden') { + return []; + } + + // “仅本部门”范围使用可见成员维度;更大范围使用当前可见组织根节点的 + // 直属下级,避免父子汇总同时参与占比。 + if ($rankingKind === 'member') { + $members = []; + self::collectRankingMembers($rows, $members); + + return array_values($members); + } + + // lists 里可能同时存在“未绑定/未分配部门”等虚拟根节点。它们会让顶层节点数量 // 大于 1,导致原逻辑无法展开唯一的真实组织根节点,图表最终只显示医院汇总行。 $visibleRows = array_values(array_filter($rows, static function (array $row): bool { return (int) ($row['id'] ?? 0) > 0 && !((bool) ($row['_virtual_bucket'] ?? false)); @@ -583,59 +657,59 @@ class FirstVisitConversionLogic $chartRows[] = $row; } - return $chartRows; - } - - /** - * @param array> $rows - * @param array> $members - */ - private static function collectRankingMembers(array $rows, array &$members): void - { - foreach ($rows as $row) { - if ((string) ($row['type'] ?? '') === 'member') { - $adminId = (int) ($row['admin_id'] ?? 0); - if ($adminId > 0) { - $members[$adminId] = $row; - } - continue; - } - self::collectRankingMembers( - is_array($row['children'] ?? null) ? $row['children'] : [], - $members - ); - } - } + return $chartRows; + } + + /** + * @param array> $rows + * @param array> $members + */ + private static function collectRankingMembers(array $rows, array &$members): void + { + foreach ($rows as $row) { + if ((string) ($row['type'] ?? '') === 'member') { + $adminId = (int) ($row['admin_id'] ?? 0); + if ($adminId > 0) { + $members[$adminId] = $row; + } + continue; + } + self::collectRankingMembers( + is_array($row['children'] ?? null) ? $row['children'] : [], + $members + ); + } + } /** @param array> $rows @return array> */ - private static function topRows(array $rows, string $metric): array - { - $rows = array_values(array_filter($rows, static function (array $row): bool { - if ((string) ($row['type'] ?? '') === 'member') { - return (int) ($row['admin_id'] ?? 0) > 0; - } - - return (int) ($row['id'] ?? 0) > 0; - })); - usort($rows, static function (array $left, array $right) use ($metric): int { - $valueCompare = (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0); - if ($valueCompare !== 0) { - return $valueCompare; - } - $nameCompare = strnatcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? '')); - if ($nameCompare !== 0) { - return $nameCompare; - } - - return strcmp((string) ($left['id'] ?? ''), (string) ($right['id'] ?? '')); - }); - - return array_map(static fn (array $row): array => [ - 'id' => $row['id'] ?? 0, - 'name' => (string) ($row['name'] ?? ''), - 'value' => round((float) ($row[$metric] ?? 0), 2), - ], $rows); - } + private static function topRows(array $rows, string $metric): array + { + $rows = array_values(array_filter($rows, static function (array $row): bool { + if ((string) ($row['type'] ?? '') === 'member') { + return (int) ($row['admin_id'] ?? 0) > 0; + } + + return (int) ($row['id'] ?? 0) > 0; + })); + usort($rows, static function (array $left, array $right) use ($metric): int { + $valueCompare = (float) ($right[$metric] ?? 0) <=> (float) ($left[$metric] ?? 0); + if ($valueCompare !== 0) { + return $valueCompare; + } + $nameCompare = strnatcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? '')); + if ($nameCompare !== 0) { + return $nameCompare; + } + + return strcmp((string) ($left['id'] ?? ''), (string) ($right['id'] ?? '')); + }); + + return array_map(static fn (array $row): array => [ + 'id' => $row['id'] ?? 0, + 'name' => (string) ($row['name'] ?? ''), + 'value' => round((float) ($row[$metric] ?? 0), 2), + ], $rows); + } /** @param int[]|null $baseVisibleAdminIds @param int[] $selectedDeptIds @return array */ private static function assistantOptions(?array $baseVisibleAdminIds, array $selectedDeptIds, int $selectedDeptId): array diff --git a/server/app/adminapi/logic/stats/ConversionLogic.php b/server/app/adminapi/logic/stats/ConversionLogic.php index a597053f9..40f427dab 100755 --- a/server/app/adminapi/logic/stats/ConversionLogic.php +++ b/server/app/adminapi/logic/stats/ConversionLogic.php @@ -62,7 +62,9 @@ class ConversionLogic if ($mediaChannel === null && $requestedMediaChannelCode !== '') { $mediaChannel = MediaChannelService::getChannelByCode($requestedMediaChannelCode); } - $mediaChannelCode = $mediaChannel !== null ? $requestedMediaChannelCode : ''; + $mediaChannelCodes = $mediaChannel !== null + ? MediaChannelService::getChannelCodesForStats($mediaChannel) + : null; $filterEmptyEntities = $mediaChannel !== null; [$startTimestamp, $endTimestamp, $startDate, $endDate] = self::resolveTimeRange($params); $pageNo = max(1, (int)($params['page_no'] ?? 1)); @@ -188,11 +190,11 @@ class ConversionLogic ); // 数据隔离:可见部门 = 可见 admin 所属部门并集;用于 account_cost 与下游 cost 分摊。 $visibleDeptIds = self::resolveVisibleDeptIds($visibleAdminIds); - [$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCode, $visibleDeptIds); + [$globalAccountCost, $accountCostDeptIds] = self::hydrateAccountCostStats($entities, $startDate, $endDate, $mediaChannelCodes, $visibleDeptIds); $supportsDeptBinding = AccountCost::supportsDeptBinding(); $restrictAccountCostByDept = $supportsDeptBinding; - $channelBoundDeptIds = $supportsDeptBinding && $mediaChannelCode !== '' - ? self::loadChannelBoundDeptIds($mediaChannelCode) + $channelBoundDeptIds = $supportsDeptBinding && $mediaChannelCodes !== null && $mediaChannelCodes !== [] + ? self::loadChannelBoundDeptIds($mediaChannelCodes) : []; // 渠道尚未维护投放成本时,不能把真实的加粉、挂号和订单一并过滤为空。 // 已维护绑定关系的渠道继续按绑定部门收窄;成本本身仍只在实际成本部门内分摊。 @@ -267,7 +269,7 @@ class ConversionLogic $startDate, $endDate, $mediaChannel, - $mediaChannelCode, + $mediaChannelCodes, $restrictAccountCostByDept, $eligibleDeptIds, $adminToDeptIds, @@ -824,17 +826,18 @@ class ConversionLogic /** * 渠道绑定部门不依赖当前统计区间,避免某天没有录入成本时把统计实体过滤为空。 * + * @param string[] $mediaChannelCodes * @return int[] */ - private static function loadChannelBoundDeptIds(string $mediaChannelCode): array + private static function loadChannelBoundDeptIds(array $mediaChannelCodes): array { - $mediaChannelCode = trim($mediaChannelCode); - if ($mediaChannelCode === '' || !AccountCost::supportsDeptBinding()) { + $mediaChannelCodes = self::normalizeMediaChannelCodes($mediaChannelCodes); + if ($mediaChannelCodes === [] || !AccountCost::supportsDeptBinding()) { return []; } $deptIds = Db::name('account_cost') - ->where('media_channel_code', $mediaChannelCode) + ->whereIn('media_channel_code', $mediaChannelCodes) ->where('dept_id', '>', 0) ->distinct(true) ->column('dept_id'); @@ -1596,6 +1599,7 @@ class ConversionLogic * 账户消耗:来源于独立维护表 zyt_account_cost。 * * @param array> $entities + * @param string[]|null $mediaChannelCodes null=不按渠道过滤;[]=已选渠道但无匹配 code,成本记 0 * @param int[]|null $visibleDeptIds 可见部门集合(null = SCOPE_ALL,不收窄) * @return array{0: float, 1: int[]} */ @@ -1603,10 +1607,21 @@ class ConversionLogic array &$entities, string $startDate, string $endDate, - string $mediaChannelCode, + ?array $mediaChannelCodes, ?array $visibleDeptIds = null ): array { + $mediaChannelCodes = $mediaChannelCodes === null ? null : self::normalizeMediaChannelCodes($mediaChannelCodes); + if ($mediaChannelCodes === []) { + foreach ($entities as &$entity) { + $entity['account_cost'] = 0.0; + $entity['_global_account_cost'] = 0.0; + } + unset($entity); + + return [0.0, []]; + } + $supportsDeptBinding = AccountCost::supportsDeptBinding(); $query = Db::name('account_cost') ->where('cost_date', '>=', $startDate) @@ -1618,8 +1633,8 @@ class ConversionLogic $query->field('amount'); } - if ($mediaChannelCode !== '') { - $query->where('media_channel_code', $mediaChannelCode); + if ($mediaChannelCodes !== null) { + $query->whereIn('media_channel_code', $mediaChannelCodes); } if ($supportsDeptBinding) { @@ -2049,7 +2064,7 @@ class ConversionLogic string $startDate, string $endDate, ?array $mediaChannel, - string $mediaChannelCode, + ?array $mediaChannelCodes, bool $restrictAccountCostByDept, array $eligibleDeptIds, array $adminToDeptIds, @@ -2081,9 +2096,9 @@ class ConversionLogic if ($globalAccountCost < 0) { // 任选一个非空 entity 集合查一次即可——查询本身只与日期 / 渠道相关。 if ($assistantIds !== []) { - [$globalAccountCost] = self::hydrateAccountCostStats($assistantEntities, $startDate, $endDate, $mediaChannelCode); + [$globalAccountCost] = self::hydrateAccountCostStats($assistantEntities, $startDate, $endDate, $mediaChannelCodes); } elseif ($doctorIds !== []) { - [$globalAccountCost] = self::hydrateAccountCostStats($doctorEntities, $startDate, $endDate, $mediaChannelCode); + [$globalAccountCost] = self::hydrateAccountCostStats($doctorEntities, $startDate, $endDate, $mediaChannelCodes); } else { $globalAccountCost = 0.0; } @@ -2905,10 +2920,29 @@ class ConversionLogic return [ 'code' => (string)($mediaChannel['channel_code'] ?? ''), 'tag_id' => (string)($mediaChannel['source_tag_id'] ?? ''), + 'tag_ids' => $mediaChannel['source_tag_ids'] ?? [], 'tag_name' => (string)($mediaChannel['source_tag_name'] ?? ''), ]; } + /** + * @param string|string[] $mediaChannelCode + * @return string[] + */ + private static function normalizeMediaChannelCodes(string|array $mediaChannelCode): array + { + $values = is_array($mediaChannelCode) ? $mediaChannelCode : [$mediaChannelCode]; + $codes = []; + foreach ($values as $value) { + $code = trim((string)$value); + if ($code !== '' && !str_starts_with($code, MediaChannelService::GROUP_CODE_PREFIX)) { + $codes[$code] = $code; + } + } + + return array_values($codes); + } + /** * @param int[]|null $visibleAdminIds * @param int[] $eligibleDeptIds diff --git a/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php b/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php index f17c972fd..213f52193 100644 --- a/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php +++ b/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php @@ -4,13 +4,12 @@ declare(strict_types=1); namespace app\adminapi\logic\tcm; -use app\common\cache\AdminAuthCache; -use app\common\logic\BaseLogic; -use app\common\model\auth\AdminRole; -use app\common\model\tcm\Diagnosis; -use app\common\model\tcm\DiagnosisAiReport; -use app\common\service\DataScope\DataScopeService; -use app\common\service\DifyChatService; +use app\common\cache\AdminAuthCache; +use app\adminapi\logic\firstvisit\MyPatientLogic; +use app\common\logic\BaseLogic; +use app\common\model\tcm\Diagnosis; +use app\common\model\tcm\DiagnosisAiReport; +use app\common\service\DifyChatService; use think\facade\Db; use think\facade\Log; @@ -201,6 +200,44 @@ class DiagnosisAiLogic extends BaseLogic string $prompt, int $adminId, array $adminInfo + ): ?array { + $prepared = self::prepareAssistant($diagnosisId, $task, $prompt, $adminId, $adminInfo); + if ($prepared === null) { + return null; + } + + try { + $result = DifyChatService::chat( + $prepared['profile'], + $prepared['inputs'], + $prepared['query'], + $prepared['user'] + ); + } catch (\Throwable $e) { + self::logAssistantFailure($diagnosisId, $prepared['profile'], $adminId, $e); + self::setError('AI 助手暂时不可用,请稍后重试'); + return null; + } + + return self::formatAssistantResult($prepared, $result); + } + + /** + * 在 SSE headers 发出前完成参数、权限、DataScope、病例和模型选择预检。 + * 返回值只供同一请求内的流执行使用,绝不能直接序列化给客户端。 + * + * @param array $adminInfo + * @return array{ + * diagnosis_id:int,profile:string,model_name:string,model_label:string,task:string, + * inputs:array,query:string,user:string,admin_id:int + * }|null + */ + public static function prepareAssistant( + int $diagnosisId, + string $task, + string $prompt, + int $adminId, + array $adminInfo ): ?array { $diagnosis = self::loadAuthorizedDiagnosis( $diagnosisId, @@ -239,28 +276,63 @@ class DiagnosisAiLogic extends BaseLogic return null; } + return [ + 'diagnosis_id' => $diagnosisId, + 'profile' => $profile, + 'model_name' => $model, + 'model_label' => $modelLabel, + 'task' => $task, + 'inputs' => self::buildUpstreamInputs( + $context, + '病例问诊助手', + self::ASSISTANT_PROMPT_VERSION + ), + 'query' => self::buildAssistantPrompt($context, $task, $prompt), + 'user' => 'admin-diagnosis-assistant-' . $adminId, + 'admin_id' => $adminId, + ]; + } + + /** + * @param array $prepared prepareAssistant() 的内部返回值 + * @param callable(string):mixed $onDelta + * @param callable():bool|null $shouldAbort + * @return array{answer:string,model_key:string,model_label:string,model_name:string,task:string}|null + */ + public static function streamPreparedAssistant( + array $prepared, + callable $onDelta, + ?callable $shouldAbort = null + ): ?array { + $diagnosisId = (int) ($prepared['diagnosis_id'] ?? 0); + $profile = (string) ($prepared['profile'] ?? ''); + $adminId = (int) ($prepared['admin_id'] ?? 0); + try { - $result = DifyChatService::chat( + $result = DifyChatService::streamChat( $profile, - self::buildUpstreamInputs( - $context, - '病例问诊助手', - self::ASSISTANT_PROMPT_VERSION - ), - self::buildAssistantPrompt($context, $task, $prompt), - 'admin-diagnosis-assistant-' . $adminId + is_array($prepared['inputs'] ?? null) ? $prepared['inputs'] : [], + (string) ($prepared['query'] ?? ''), + (string) ($prepared['user'] ?? ''), + $onDelta, + $shouldAbort ); } catch (\Throwable $e) { - Log::warning('diagnosis ai assistant upstream call failed', [ - 'diagnosis_id' => $diagnosisId, - 'profile' => $profile, - 'admin_id' => $adminId, - 'exception_class' => get_class($e), - ]); + self::logAssistantFailure($diagnosisId, $profile, $adminId, $e); self::setError('AI 助手暂时不可用,请稍后重试'); return null; } + return self::formatAssistantResult($prepared, $result); + } + + /** + * @param array $prepared + * @param array $result + * @return array{answer:string,model_key:string,model_label:string,model_name:string,task:string}|null + */ + private static function formatAssistantResult(array $prepared, array $result): ?array + { if (empty($result['ok'])) { self::setError((string) ($result['error'] ?? 'AI 助手暂时不可用,请稍后重试')); return null; @@ -273,13 +345,27 @@ class DiagnosisAiLogic extends BaseLogic return [ 'answer' => $content, - 'model_key' => $profile, - 'model_label' => $modelLabel, - 'model_name' => $model, - 'task' => $task, + 'model_key' => (string) ($prepared['profile'] ?? ''), + 'model_label' => (string) ($prepared['model_label'] ?? ''), + 'model_name' => (string) ($prepared['model_name'] ?? ''), + 'task' => (string) ($prepared['task'] ?? ''), ]; } + private static function logAssistantFailure( + int $diagnosisId, + string $profile, + int $adminId, + \Throwable $exception + ): void { + Log::warning('diagnosis ai assistant upstream call failed', [ + 'diagnosis_id' => $diagnosisId, + 'profile' => $profile, + 'admin_id' => $adminId, + 'exception_class' => get_class($exception), + ]); + } + /** * 接诊台结构化 AI 智能分析。每次只调用客户端白名单键对应的服务端模型, * 上游失败或响应不符合契约时直接失败,不构造本地伪分析。 @@ -604,28 +690,10 @@ class DiagnosisAiLogic extends BaseLogic return null; } - $accessQuery = Diagnosis::where('id', $id)->whereNull('delete_time'); - $isRoot = !empty($adminInfo['root']) && (int) $adminInfo['root'] === 1; - if (!$isRoot) { - $roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id')); - if (in_array(2, $roleIds, true)) { - $accessQuery->where('assistant_id', $adminId); - } - if (DataScopeService::isEnabled()) { - $visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo); - if ($visibleIds === []) { - self::setError('诊单不存在或无权访问'); - return null; - } - if (is_array($visibleIds)) { - $accessQuery->whereIn('assistant_id', $visibleIds); - } - } + if (!MyPatientLogic::canAccessDiagnosis($id, $adminId, $adminInfo)) { + self::setError('诊单不存在或无权访问'); + return null; } - if (!$accessQuery->find()) { - self::setError('诊单不存在或无权访问'); - return null; - } $diagnosis = DiagnosisLogic::detail(['id' => $id], $adminInfo); if ($diagnosis === [] || empty($diagnosis['id'])) { diff --git a/server/app/adminapi/logic/tcm/DiagnosisLogic.php b/server/app/adminapi/logic/tcm/DiagnosisLogic.php index 42dd19db6..a7e4b7a7f 100755 --- a/server/app/adminapi/logic/tcm/DiagnosisLogic.php +++ b/server/app/adminapi/logic/tcm/DiagnosisLogic.php @@ -28,10 +28,11 @@ use app\common\model\DiagnosisViewRecord; use app\common\model\doctor\Appointment; use app\common\model\auth\Admin; use app\common\model\auth\AdminRole; -use app\adminapi\logic\auth\AuthLogic; -use app\adminapi\logic\doctor\DoctorNoteLogic; -use app\adminapi\logic\doctor\AppointmentLogic; -use app\adminapi\logic\tcm\TrackingNoteLogic; +use app\adminapi\logic\auth\AuthLogic; +use app\adminapi\logic\doctor\DoctorNoteLogic; +use app\adminapi\logic\doctor\AppointmentLogic; +use app\adminapi\logic\firstvisit\MyPatientLogic; +use app\adminapi\logic\tcm\TrackingNoteLogic; use app\common\service\ConfigService; use app\common\service\FileService; use app\common\service\DataScope\DataScopeService; @@ -4182,32 +4183,10 @@ class DiagnosisLogic extends BaseLogic return []; } - // 1) 数据权限闸 — 不通过则返回「不存在或无权访问」 - $accessQuery = Diagnosis::where('id', $diagnosisId)->whereNull('delete_time'); - - $roleIds = array_map('intval', AdminRole::where('admin_id', $adminId)->column('role_id')); - if (in_array(2, $roleIds, true)) { - // 医助仅看自己被指派的 - $accessQuery->where('assistant_id', $adminId); - } - - if (DataScopeService::isEnabled()) { - $visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo); - if ($visibleIds === []) { - self::setError('诊单不存在或无权访问'); - - return []; - } - if (is_array($visibleIds)) { - $accessQuery->whereIn('assistant_id', $visibleIds); - } - } - - if (!$accessQuery->find()) { - self::setError('诊单不存在或无权访问'); - - return []; - } + // 1) 数据权限闸 — 不通过则返回「不存在或无权访问」 + if (!self::canViewReadonlyDiagnosis($diagnosisId, $adminId, $adminInfo)) { + return []; + } // 2) 诊单详情(含图片聚合等)+ 字典翻译 $diagnosis = self::detail(['id' => $diagnosisId]); @@ -4238,24 +4217,43 @@ class DiagnosisLogic extends BaseLogic $unservedDays = $maxRecordTs > 0 ? max(0, (int) floor((time() - $maxRecordTs) / 86400)) : null; $lastBloodRecordAt = $maxRecordTs > 0 ? date('Y-m-d', $maxRecordTs) : null; - return [ + return [ 'appointment' => $appointment, 'diagnosis' => $diagnosis, 'doctor_notes' => $doctorNotes, 'tracking_notes' => $trackingNotes, 'unserved_days' => $unservedDays, 'last_blood_record_at' => $lastBloodRecordAt, - ]; - } - - /** + ]; + } + + /** + * 与 readonlyDetail 共用的诊单行级可见性入口。 + * + * 复用“我的患者”统一行权策略:医生按有效接诊关系,医助按归属关系, + * 团队管理角色才使用 DataScope。不存在与越权使用同一错误避免枚举。 + */ + public static function canViewReadonlyDiagnosis(int $diagnosisId, int $adminId, array $adminInfo): bool + { + self::$error = ''; + if (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $adminId, $adminInfo)) { + self::setError('诊单不存在或无权访问'); + + return false; + } + + return true; + } + + /** * 取指定日期区间内的三类跟踪记录(血糖血压 / 饮食 / 运动),供 readonlyDetail 与 * 医生接诊台 reception 通过独立接口 lazy load。 * * 区间语义:闭区间 [startDate, endDate](Y-m-d),均不传则不限。 * * @return array{ - * blood_records: array>, + * diagnosis_id: int, + * blood_records: array>, * diet_records: array>, * exercise_records: array>, * start_date: string, @@ -4267,10 +4265,11 @@ class DiagnosisLogic extends BaseLogic $sinceTs = $startDate !== '' ? (int) strtotime($startDate . ' 00:00:00') : 0; $untilTs = $endDate !== '' ? (int) strtotime($endDate . ' 23:59:59') : 0; $sinceTs = $sinceTs > 0 ? $sinceTs : 0; - $untilTs = $untilTs > 0 ? $untilTs : 0; - - return [ - 'blood_records' => self::fetchBloodRecordsForReadonly($diagnosisId, $sinceTs, $untilTs), + $untilTs = $untilTs > 0 ? $untilTs : 0; + + return [ + 'diagnosis_id' => $diagnosisId, + 'blood_records' => self::fetchBloodRecordsForReadonly($diagnosisId, $sinceTs, $untilTs), 'diet_records' => self::fetchDietRecordsForReadonly($diagnosisId, $sinceTs, $untilTs), 'exercise_records' => self::fetchExerciseRecordsForReadonly($diagnosisId, $sinceTs, $untilTs), 'start_date' => $startDate, diff --git a/server/app/adminapi/logic/tcm/PrescriptionLogic.php b/server/app/adminapi/logic/tcm/PrescriptionLogic.php index a9fa50b01..0d2410067 100755 --- a/server/app/adminapi/logic/tcm/PrescriptionLogic.php +++ b/server/app/adminapi/logic/tcm/PrescriptionLogic.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace app\adminapi\logic\tcm; - -use app\common\model\auth\Admin; +namespace app\adminapi\logic\tcm; + +use app\adminapi\logic\firstvisit\MyPatientLogic; +use app\common\model\auth\Admin; use app\common\model\doctor\Appointment; use app\common\model\doctor\Medicine as DoctorMedicine; use app\common\model\tcm\Prescription; @@ -934,14 +935,35 @@ class PrescriptionLogic /** * 根据诊单ID获取处方列表 */ - public static function listByDiagnosis(int $diagnosisId): array - { - return Prescription::where('diagnosis_id', $diagnosisId) - ->whereNull('delete_time') - ->order('id', 'desc') - ->select() - ->toArray(); - } + public static function listByDiagnosis(int $diagnosisId, int $viewerAdminId, array $viewerAdminInfo): array + { + self::$error = ''; + if (!MyPatientLogic::canAccessDiagnosis($diagnosisId, $viewerAdminId, $viewerAdminInfo)) { + self::setError('诊单不存在或无权访问'); + + return []; + } + + $rows = Prescription::where('diagnosis_id', $diagnosisId) + ->whereNull('delete_time') + ->order('id', 'desc') + ->select() + ->toArray(); + + return self::filterViewablePrescriptions($rows, $viewerAdminId, $viewerAdminInfo); + } + + /** + * @param array> $rows + * @return array> + */ + private static function filterViewablePrescriptions(array $rows, int $viewerAdminId, array $viewerAdminInfo): array + { + return array_values(array_filter( + $rows, + static fn (array $row): bool => self::canViewPrescription($row, $viewerAdminId, $viewerAdminInfo) + )); + } /** * 根据预约ID获取处方(带权限检查) diff --git a/server/app/adminapi/service/AssistantSseProtocol.php b/server/app/adminapi/service/AssistantSseProtocol.php new file mode 100644 index 000000000..b718a97d9 --- /dev/null +++ b/server/app/adminapi/service/AssistantSseProtocol.php @@ -0,0 +1,49 @@ + delta* -> done|error。 + */ +final class AssistantSseProtocol +{ + private int $seq = 0; + + private bool $started = false; + + private bool $terminal = false; + + /** @param array $payload */ + public function encode(string $event, array $payload): ?string + { + if ($this->terminal || !in_array($event, ['start', 'delta', 'done', 'error'], true)) { + return null; + } + if ((!$this->started && $event !== 'start') || ($this->started && $event === 'start')) { + return null; + } + + $nextSeq = $this->seq + 1; + $encoded = json_encode( + ['seq' => $nextSeq] + $payload, + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE + ); + if (!is_string($encoded)) { + return null; + } + + $this->seq = $nextSeq; + $this->started = true; + if (in_array($event, ['done', 'error'], true)) { + $this->terminal = true; + } + return 'event: ' . $event . "\n" . 'data: ' . $encoded . "\n\n"; + } + + public function isTerminal(): bool + { + return $this->terminal; + } +} diff --git a/server/app/common/service/DifyChatService.php b/server/app/common/service/DifyChatService.php index 3395b9b44..b0c31205a 100644 --- a/server/app/common/service/DifyChatService.php +++ b/server/app/common/service/DifyChatService.php @@ -98,6 +98,114 @@ class DifyChatService ], $startedAt); } + /** + * 流式调用 Dify / OpenAI-compatible 接口。上游原始响应与凭据不会进入返回值。 + * + * @param array $inputs + * @param callable(string):mixed $onDelta + * @param callable():bool|null $shouldAbort + * @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string} + */ + public static function streamChat( + string $profile, + array $inputs, + string $query, + string $user, + callable $onDelta, + ?callable $shouldAbort = null + ): array { + $config = config('prescription_ai') ?: []; + if (empty($config['enable'])) { + return self::error('CONFIG_DISABLED', 'AI 报告功能未启用'); + } + + $modelConfig = self::resolveProfileConfig($config, $profile); + if ($modelConfig === null) { + return self::error('INVALID_PROFILE', '不支持的 AI 模型'); + } + + $baseUrl = trim((string) ($config['base_url'] ?? '')); + $rawApiKey = (string) ($modelConfig['api_key'] ?? ''); + $apiKey = trim($rawApiKey); + if ($baseUrl === '' || $apiKey === '') { + return self::error('CONFIG_MISSING', '该模型服务尚未完整配置'); + } + if (!self::isValidBaseUrl($baseUrl) || strpbrk($rawApiKey, "\r\n") !== false) { + return self::error('CONFIG_INVALID', 'AI 服务配置无效'); + } + + $timeout = (int) ($config['timeout'] ?? 0); + if (!self::isValidTimeout($timeout)) { + return self::error('CONFIG_INVALID', 'AI 服务超时配置无效'); + } + if (!function_exists('curl_init')) { + return self::error('CURL_UNAVAILABLE', '服务器尚未启用 cURL 扩展'); + } + + $model = trim((string) ($modelConfig['name'] ?? '')); + if ($model === '') { + return self::error('CONFIG_INVALID', 'AI 模型配置无效'); + } + + $requestSpecs = self::buildRequestSpecs( + $baseUrl, + $model, + $inputs, + $query, + $user, + true + ); + $startedAt = microtime(true); + $lastResponse = null; + + foreach ($requestSpecs as $index => $requestSpec) { + $elapsedSeconds = (int) floor(microtime(true) - $startedAt); + $remainingTimeout = $timeout - $elapsedSeconds; + if ($remainingTimeout < self::MIN_TIMEOUT) { + return self::error( + 'UPSTREAM_TIMEOUT', + '模型响应超时,请稍后重试', + self::elapsedMilliseconds($startedAt) + ); + } + + $response = self::sendStreamRequest( + $requestSpec['protocol'], + $requestSpec['url'], + $requestSpec['payload'], + $apiKey, + $remainingTimeout, + $onDelta, + $shouldAbort + ); + $lastResponse = $response; + + // 只在尚未向下游发送任何文本、且明确为路径不支持时尝试另一协议。 + $hasFallback = isset($requestSpecs[$index + 1]); + if ( + $hasFallback + && empty($response['emitted']) + && in_array($response['http_code'], [404, 405], true) + ) { + continue; + } + + return self::formatStreamResponse($response, $startedAt); + } + + return self::formatStreamResponse($lastResponse ?? [ + 'errno' => 0, + 'http_code' => 0, + 'content' => '', + 'message_id' => '', + 'emitted' => false, + 'upstream_error' => false, + 'client_aborted' => false, + 'callback_error' => false, + 'finished' => false, + ], $startedAt); + } + /** * @param array $config * @return array|null @@ -120,7 +228,8 @@ class DifyChatService string $model, array $inputs, string $query, - string $user + string $user, + bool $streaming = false ): array { $baseUrl = rtrim($baseUrl, '/'); $path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? '')); @@ -131,7 +240,7 @@ class DifyChatService 'payload' => [ 'inputs' => $inputs, 'query' => $query, - 'response_mode' => 'blocking', + 'response_mode' => $streaming ? 'streaming' : 'blocking', 'user' => $user, ], ]; @@ -143,9 +252,14 @@ class DifyChatService 'messages' => [ ['role' => 'user', 'content' => $query], ], + 'stream' => $streaming, ], ]; + if (!$streaming) { + unset($openAiSpec['payload']['stream']); + } + if (str_ends_with($path, '/chat-messages')) { return [$difySpec]; } @@ -240,6 +354,357 @@ class DifyChatService ]; } + /** + * @param array $payload + * @param callable(string):mixed $onDelta + * @param callable():bool|null $shouldAbort + * @return array{ + * errno:int,http_code:int,content:string,message_id:string,emitted:bool, + * upstream_error:bool,client_aborted:bool,callback_error:bool,finished:bool + * } + */ + private static function sendStreamRequest( + string $protocol, + string $url, + array $payload, + string $apiKey, + int $timeout, + callable $onDelta, + ?callable $shouldAbort + ): array { + $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); + if ($body === false) { + return self::emptyStreamResponse(-1); + } + + $ch = curl_init(); + if ($ch === false) { + return self::emptyStreamResponse(-2); + } + + $buffer = ''; + $state = self::newStreamState(); + $responseCode = 0; + $header = static function ($handle, string $line) use (&$responseCode): int { + if (preg_match('/^HTTP\/\S+\s+(\d{3})(?:\s|$)/i', trim($line), $matches) === 1) { + $responseCode = (int) $matches[1]; + } + return strlen($line); + }; + $write = static function ($handle, string $chunk) use ( + $protocol, + &$buffer, + &$state, + &$responseCode, + $onDelta, + $shouldAbort + ): int { + if ($shouldAbort !== null && $shouldAbort()) { + $state['client_aborted'] = true; + return 0; + } + if ($responseCode < 200 || $responseCode >= 300) { + // Never decode or forward an error response body. Besides preventing + // leakage, this keeps 404/405 protocol fallback side-effect free. + return strlen($chunk); + } + self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $onDelta); + return $state['callback_error'] ? 0 : strlen($chunk); + }; + $progress = static function () use (&$state, $shouldAbort): int { + if ($shouldAbort !== null && $shouldAbort()) { + $state['client_aborted'] = true; + return 1; + } + return 0; + }; + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_RETURNTRANSFER => false, + CURLOPT_CONNECTTIMEOUT => min(8, max(1, (int) ceil($timeout / 4))), + CURLOPT_TIMEOUT => $timeout, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Accept: text/event-stream', + 'Authorization: Bearer ' . $apiKey, + ], + CURLOPT_HEADERFUNCTION => $header, + CURLOPT_WRITEFUNCTION => $write, + CURLOPT_NOPROGRESS => false, + CURLOPT_XFERINFOFUNCTION => $progress, + ]); + + curl_exec($ch); + $errno = curl_errno($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if (!$state['client_aborted'] && !$state['callback_error']) { + self::consumeStreamBytes($protocol, $buffer, '', $state, $onDelta, true); + } + + return [ + 'errno' => $errno, + 'http_code' => $httpCode, + 'content' => $state['content'], + 'message_id' => $state['message_id'], + 'emitted' => $state['emitted'], + 'upstream_error' => $state['upstream_error'], + 'client_aborted' => $state['client_aborted'], + 'callback_error' => $state['callback_error'], + 'finished' => $state['finished'], + ]; + } + + /** + * @return array{ + * content:string,message_id:string,emitted:bool,upstream_error:bool, + * client_aborted:bool,callback_error:bool,finished:bool + * } + */ + private static function newStreamState(): array + { + return [ + 'content' => '', + 'message_id' => '', + 'emitted' => false, + 'upstream_error' => false, + 'client_aborted' => false, + 'callback_error' => false, + 'finished' => false, + ]; + } + + /** + * 按 SSE 空行分帧;仅在完整 data frame 后 json_decode,因此可安全接收任意字节边界。 + * + * @param array $state + * @param callable(string):mixed $onDelta + */ + private static function consumeStreamBytes( + string $protocol, + string &$buffer, + string $chunk, + array &$state, + callable $onDelta, + bool $final = false + ): void { + $buffer .= $chunk; + while (preg_match('/(?:\r\n|\r|\n){2}/', $buffer, $match, PREG_OFFSET_CAPTURE) === 1) { + $delimiter = $match[0][0]; + $offset = $match[0][1]; + $frame = substr($buffer, 0, $offset); + $buffer = (string) substr($buffer, $offset + strlen($delimiter)); + self::consumeStreamFrame($protocol, $frame, $state, $onDelta); + } + if ($final && trim($buffer) !== '') { + self::consumeStreamFrame($protocol, $buffer, $state, $onDelta); + $buffer = ''; + } + } + + /** + * @param array $state + * @param callable(string):mixed $onDelta + */ + private static function consumeStreamFrame( + string $protocol, + string $frame, + array &$state, + callable $onDelta + ): void { + if ($state['finished'] || $state['upstream_error'] || $state['callback_error']) { + return; + } + + $dataLines = []; + foreach (preg_split('/\r\n|\r|\n/', $frame) ?: [] as $line) { + if ($line === '' || str_starts_with($line, ':')) { + continue; + } + if (str_starts_with($line, 'data:')) { + $dataLines[] = ltrim(substr($line, 5), ' '); + } + } + if ($dataLines === []) { + return; + } + + $data = implode("\n", $dataLines); + if ($data === '[DONE]') { + $state['finished'] = true; + return; + } + $decoded = json_decode($data, true); + if (!is_array($decoded)) { + return; + } + + $delta = ''; + if ($protocol === 'dify') { + $event = strtolower((string) ($decoded['event'] ?? '')); + if ($event === 'message_end') { + $state['message_id'] = (string) ($decoded['message_id'] ?? $state['message_id']); + $state['finished'] = true; + return; + } + if ($event === 'error') { + $state['upstream_error'] = true; + return; + } + if (!in_array($event, ['message', 'agent_message'], true)) { + return; + } + $delta = is_string($decoded['answer'] ?? null) ? $decoded['answer'] : ''; + $state['message_id'] = (string) ($decoded['message_id'] ?? $state['message_id']); + } else { + $delta = self::extractStreamDelta($decoded); + $state['message_id'] = (string) ($decoded['id'] ?? $state['message_id']); + } + + if ($delta === '') { + return; + } + try { + $accepted = $onDelta($delta); + if ($accepted === false) { + $state['callback_error'] = true; + return; + } + } catch (\Throwable $e) { + $state['callback_error'] = true; + return; + } + $state['content'] .= $delta; + $state['emitted'] = true; + } + + /** @param array $decoded */ + private static function extractStreamDelta(array $decoded): string + { + $content = $decoded['choices'][0]['delta']['content'] ?? ''; + if (is_string($content)) { + return $content; + } + if (!is_array($content)) { + return ''; + } + $parts = []; + foreach ($content as $part) { + if (is_array($part) && ($part['type'] ?? '') === 'text' && is_string($part['text'] ?? null)) { + $parts[] = $part['text']; + } + } + return implode('', $parts); + } + + /** + * 纯解析测试入口:生产流与测试使用同一逐字节解码路径。 + * + * @param array $chunks + * @return array{content:string,deltas:array,message_id:string,finished:bool,upstream_error:bool} + */ + private static function decodeStreamChunks(string $protocol, array $chunks): array + { + $buffer = ''; + $state = self::newStreamState(); + $deltas = []; + $onDelta = static function (string $delta) use (&$deltas): void { + $deltas[] = $delta; + }; + foreach ($chunks as $chunk) { + self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $onDelta); + } + self::consumeStreamBytes($protocol, $buffer, '', $state, $onDelta, true); + return [ + 'content' => $state['content'], + 'deltas' => $deltas, + 'message_id' => $state['message_id'], + 'finished' => $state['finished'], + 'upstream_error' => $state['upstream_error'], + ]; + } + + /** + * @return array{ + * errno:int,http_code:int,content:string,message_id:string,emitted:bool, + * upstream_error:bool,client_aborted:bool,callback_error:bool,finished:bool + * } + */ + private static function emptyStreamResponse(int $errno): array + { + return [ + 'errno' => $errno, + 'http_code' => 0, + 'content' => '', + 'message_id' => '', + 'emitted' => false, + 'upstream_error' => false, + 'client_aborted' => false, + 'callback_error' => false, + 'finished' => false, + ]; + } + + /** + * @param array $response + * @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string} + */ + private static function formatStreamResponse(array $response, float $startedAt): array + { + $latencyMs = self::elapsedMilliseconds($startedAt); + if (!empty($response['client_aborted'])) { + return self::error('CLIENT_DISCONNECTED', '客户端已断开连接', $latencyMs); + } + if (!empty($response['callback_error'])) { + return self::error('STREAM_DELIVERY_FAILED', '流式响应已中止', $latencyMs); + } + + $errno = (int) ($response['errno'] ?? 0); + if ($errno !== 0) { + if ($errno === CURLE_OPERATION_TIMEDOUT) { + return self::error('UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', $latencyMs); + } + if ($errno === -1) { + return self::error('REQUEST_BUILD_FAILED', '病例数据编码失败', $latencyMs); + } + if ($errno === -2) { + return self::error('CURL_INIT_FAILED', '无法初始化 AI 请求', $latencyMs); + } + return self::error('UPSTREAM_UNAVAILABLE', '暂时无法连接 AI 服务,请稍后重试', $latencyMs); + } + + $httpCode = (int) ($response['http_code'] ?? 0); + if ($httpCode === 401 || $httpCode === 403) { + return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs); + } + if ($httpCode === 429 || $httpCode >= 500) { + return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs); + } + if ($httpCode >= 400 || $httpCode < 200 || !empty($response['upstream_error'])) { + return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs); + } + if (empty($response['finished'])) { + return self::error('INCOMPLETE_RESPONSE', '模型响应不完整,请重试', $latencyMs); + } + + $content = (string) ($response['content'] ?? ''); + if (trim($content) === '') { + return self::error('EMPTY_RESPONSE', '模型未返回报告内容,请重试', $latencyMs); + } + return [ + 'ok' => true, + 'content' => $content, + 'message_id' => (string) ($response['message_id'] ?? ''), + 'latency_ms' => $latencyMs, + ]; + } + /** * @param array{body:string,errno:int,http_code:int} $response * @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string} diff --git a/server/app/common/service/qywx/MediaChannelService.php b/server/app/common/service/qywx/MediaChannelService.php index 1fb439096..73a8fa2df 100755 --- a/server/app/common/service/qywx/MediaChannelService.php +++ b/server/app/common/service/qywx/MediaChannelService.php @@ -21,6 +21,8 @@ class MediaChannelService 'update_time', ]; + public const GROUP_CODE_PREFIX = 'group:'; + /** @var array>|null */ private static ?array $activeChannelRowsCache = null; @@ -199,8 +201,9 @@ SQL; 'code' => (string) ($row['channel_code'] ?? ''), 'name' => (string) ($row['channel_name'] ?? ''), 'tag_id' => (string) ($row['source_tag_id'] ?? ''), - 'group_name' => (string) ($row['source_group_name'] ?? ''), + 'group_name' => trim((string) ($row['source_group_name'] ?? '')), 'customer_count' => (int) ($row['customer_count'] ?? 0), + 'kind' => 'channel', ], self::getCurrentTagChannelRows()); } @@ -217,6 +220,11 @@ SQL; return null; } + $groupName = self::parseGroupName($channelCode); + if ($groupName !== '') { + return self::buildCurrentTagGroupChannel($groupName); + } + foreach (self::getCurrentTagChannelRows() as $row) { if ((string) ($row['channel_code'] ?? '') === $channelCode) { return $row; @@ -226,6 +234,57 @@ SQL; return null; } + public static function isGroupCode(string $channelCode): bool + { + return self::parseGroupName($channelCode) !== ''; + } + + public static function buildGroupCode(string $groupName): string + { + $groupName = trim($groupName); + + return $groupName === '' ? '' : self::GROUP_CODE_PREFIX . $groupName; + } + + public static function parseGroupName(string $channelCode): string + { + $channelCode = trim($channelCode); + if (!str_starts_with($channelCode, self::GROUP_CODE_PREFIX)) { + return ''; + } + + return trim(substr($channelCode, strlen(self::GROUP_CODE_PREFIX))); + } + + /** + * 账户消耗等事实表使用的真实渠道 code;分组筛选会展开为组内全部叶子渠道。 + * + * @param array|null $channel + * @return string[] + */ + public static function getChannelCodesForStats(?array $channel): array + { + if ($channel === null) { + return []; + } + + $codes = []; + if (isset($channel['channel_codes']) && is_array($channel['channel_codes'])) { + foreach ($channel['channel_codes'] as $code) { + $code = trim((string) $code); + if ($code !== '' && !str_starts_with($code, self::GROUP_CODE_PREFIX)) { + $codes[$code] = $code; + } + } + } + $code = trim((string) ($channel['channel_code'] ?? '')); + if ($code !== '' && !str_starts_with($code, self::GROUP_CODE_PREFIX)) { + $codes[$code] = $code; + } + + return array_values($codes); + } + public static function getDefaultCode(): string { $rows = self::getActiveChannelRows(); @@ -303,12 +362,21 @@ SQL; return []; } - $names = array_values(array_unique(array_filter([ + $names = [ trim((string) ($channel['channel_name'] ?? '')), trim((string) ($channel['source_tag_name'] ?? '')), trim((string) ($channel['legacy_channel_name'] ?? '')), trim((string) ($channel['legacy_source_tag_name'] ?? '')), - ]))); + ]; + foreach (['channel_names', 'source_tag_names'] as $listKey) { + if (!isset($channel[$listKey]) || !is_array($channel[$listKey])) { + continue; + } + foreach ($channel[$listKey] as $name) { + $names[] = trim((string) $name); + } + } + $names = array_values(array_unique(array_filter($names, static fn (string $name): bool => $name !== ''))); if ($names === []) { return []; @@ -362,18 +430,22 @@ SQL; return; } - $tagId = trim((string) ($channel['source_tag_id'] ?? '')); - if ($tagId !== '') { + $tagIds = self::channelTagIds($channel); + if ($tagIds !== []) { $tagTable = self::tableWithPrefix('qywx_external_contact_tag'); $contactTable = self::tableWithPrefix('qywx_external_contact'); + $tagPredicate = count($tagIds) === 1 + ? 'channel_tag.tag_id = ?' + : 'channel_tag.tag_id IN (' . implode(', ', array_fill(0, count($tagIds), '?')) . ')'; + // 相关 EXISTS 走 (tag_id, external_userid) 索引,避免先物化整渠客户 ID 再 IN。 $query->whereRaw( - "{$field} IN (" - . "SELECT channel_tag.external_userid FROM {$tagTable} channel_tag " - . 'WHERE channel_tag.tag_id = ? ' + "EXISTS (SELECT 1 FROM {$tagTable} channel_tag " + . "WHERE channel_tag.external_userid = {$field} " + . "AND {$tagPredicate} " . "AND EXISTS (SELECT 1 FROM {$contactTable} active_channel_contact " . 'WHERE active_channel_contact.external_userid = channel_tag.external_userid ' . 'AND active_channel_contact.delete_time IS NULL))', - [$tagId] + $tagIds ); return; @@ -768,16 +840,24 @@ SQL; private static function buildLikePatterns(array $channel): array { $patterns = []; - $tagId = trim((string) ($channel['source_tag_id'] ?? '')); - $tagName = trim((string) ($channel['source_tag_name'] ?? '')); - - if ($tagId !== '') { + foreach (self::channelTagIds($channel) as $tagId) { $escapedTagId = addcslashes($tagId, '%_\\'); $patterns[] = '%"tag_id":"' . $escapedTagId . '"%'; $patterns[] = '%"id":"' . $escapedTagId . '"%'; } - if ($tagName !== '') { + $tagNames = [trim((string) ($channel['source_tag_name'] ?? ''))]; + if (isset($channel['channel_names']) && is_array($channel['channel_names'])) { + foreach ($channel['channel_names'] as $name) { + $tagNames[] = trim((string) $name); + } + } + if (isset($channel['source_tag_names']) && is_array($channel['source_tag_names'])) { + foreach ($channel['source_tag_names'] as $name) { + $tagNames[] = trim((string) $name); + } + } + foreach (array_unique(array_filter($tagNames, static fn (string $name): bool => $name !== '')) as $tagName) { $escapedTagName = addcslashes($tagName, '%_\\'); $patterns[] = '%"name":"' . $escapedTagName . '"%'; $patterns[] = '%"tag_name":"' . $escapedTagName . '"%'; @@ -786,6 +866,86 @@ SQL; return array_values(array_unique($patterns)); } + /** + * @param array $channel + * @return string[] + */ + private static function channelTagIds(array $channel): array + { + $tagIds = []; + if (isset($channel['source_tag_ids']) && is_array($channel['source_tag_ids'])) { + foreach ($channel['source_tag_ids'] as $tagId) { + $tagId = trim((string) $tagId); + if ($tagId !== '') { + $tagIds[$tagId] = $tagId; + } + } + } + $tagId = trim((string) ($channel['source_tag_id'] ?? '')); + if ($tagId !== '') { + $tagIds[$tagId] = $tagId; + } + + return array_values($tagIds); + } + + /** + * @return array|null + */ + private static function buildCurrentTagGroupChannel(string $groupName): ?array + { + $groupName = trim($groupName); + if ($groupName === '') { + return null; + } + + $rows = []; + foreach (self::getCurrentTagChannelRows() as $row) { + if (trim((string) ($row['source_group_name'] ?? '')) === $groupName) { + $rows[] = $row; + } + } + if ($rows === []) { + return null; + } + + $tagIds = []; + $codes = []; + $names = []; + $customerCount = 0; + foreach ($rows as $row) { + $tagId = trim((string) ($row['source_tag_id'] ?? '')); + if ($tagId !== '') { + $tagIds[$tagId] = $tagId; + } + $code = trim((string) ($row['channel_code'] ?? '')); + if ($code !== '' && !str_starts_with($code, self::GROUP_CODE_PREFIX)) { + $codes[$code] = $code; + } + foreach (['channel_name', 'source_tag_name', 'legacy_channel_name', 'legacy_source_tag_name'] as $nameKey) { + $name = trim((string) ($row[$nameKey] ?? '')); + if ($name !== '') { + $names[$name] = $name; + } + } + $customerCount = max($customerCount, (int) ($row['customer_count'] ?? 0)); + } + + return [ + 'channel_code' => self::buildGroupCode($groupName), + 'channel_name' => $groupName, + 'source_group_name' => $groupName, + 'source_tag_id' => '', + 'source_tag_name' => $groupName, + 'source_tag_ids' => array_values($tagIds), + 'channel_codes' => array_values($codes), + 'channel_names' => array_values($names), + 'customer_count' => $customerCount, + 'is_group' => true, + 'status' => 1, + ]; + } + private static function tableWithPrefix(string $table): string { $prefix = (string) (Db::getConfig('connections.mysql.prefix') ?: 'zyt_'); diff --git a/server/sql/1.9.20260818/add_first_visit_conversion_finance_perm.sql b/server/sql/1.9.20260818/add_first_visit_conversion_finance_perm.sql new file mode 100644 index 000000000..73cb05820 --- /dev/null +++ b/server/sql/1.9.20260818/add_first_visit_conversion_finance_perm.sql @@ -0,0 +1,42 @@ +-- 一诊 / 综合数据转化:现金成本与 ROI 可见权限 +-- 权限:firstvisit.conversion/viewFinance +-- 经理、管理员默认可见;诊室组长、医助需在角色里勾选本权限后才可见。 + +START TRANSACTION; + +SET @first_visit_conversion_menu_id = ( + SELECT `id` FROM `zyt_system_menu` + WHERE `perms` = 'firstvisit.conversion/overview' + OR TRIM(`component`) = 'first_visit/conversion/index' + ORDER BY CASE WHEN `perms` = 'firstvisit.conversion/overview' THEN 0 ELSE 1 END, `id` + LIMIT 1 +); + +INSERT INTO `zyt_system_menu` + (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`) +SELECT + @first_visit_conversion_menu_id, 'A', '查看现金成本与ROI', '', 10, + 'firstvisit.conversion/viewFinance', '', '', + '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP() +FROM DUAL +WHERE @first_visit_conversion_menu_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM `zyt_system_menu` + WHERE `perms` = 'firstvisit.conversion/viewFinance' + ); + +SET @first_visit_conversion_finance_menu_id = ( + SELECT `id` FROM `zyt_system_menu` + WHERE `perms` = 'firstvisit.conversion/viewFinance' + ORDER BY `id` + LIMIT 1 +); + +INSERT IGNORE INTO `zyt_system_role_menu` (`role_id`, `menu_id`) +SELECT `id`, @first_visit_conversion_finance_menu_id +FROM `zyt_system_role` +WHERE @first_visit_conversion_finance_menu_id IS NOT NULL + AND `delete_time` IS NULL + AND `name` IN ('经理', '管理员', '系统管理员'); + +COMMIT; diff --git a/server/tests/DiagnosisAiAssistantStreamContractTest.php b/server/tests/DiagnosisAiAssistantStreamContractTest.php new file mode 100644 index 000000000..b17f237d9 --- /dev/null +++ b/server/tests/DiagnosisAiAssistantStreamContractTest.php @@ -0,0 +1,121 @@ +} */ +function parseAssistantSse(string $frame): array +{ + $lines = preg_split('/\r\n|\r|\n/', trim($frame)) ?: []; + $event = ''; + $data = ''; + foreach ($lines as $line) { + if (str_starts_with($line, 'event: ')) { + $event = substr($line, 7); + } elseif (str_starts_with($line, 'data: ')) { + $data .= substr($line, 6); + } + } + $decoded = json_decode($data, true); + assistantStreamExpect($event !== '' && is_array($decoded), 'SSE frame is parseable'); + return ['event' => $event, 'data' => $decoded]; +} + +$protocol = new AssistantSseProtocol(); +assistantStreamExpect($protocol->encode('delta', ['text' => 'early']) === null, 'delta cannot precede start'); +$start = parseAssistantSse((string) $protocol->encode('start', ['message' => 'ready'])); +assistantStreamExpect($start['event'] === 'start' && $start['data']['seq'] === 1, 'start is the first event with seq 1'); +assistantStreamExpect($protocol->encode('start', []) === null, 'start can only be emitted once'); +$deltaOne = parseAssistantSse((string) $protocol->encode('delta', ['text' => '你'])); +$deltaTwo = parseAssistantSse((string) $protocol->encode('delta', ['text' => '好'])); +assistantStreamExpect($deltaOne['data']['seq'] === 2 && $deltaTwo['data']['seq'] === 3, 'delta seq is strictly monotonic'); +$done = parseAssistantSse((string) $protocol->encode('done', ['answer' => '你好'])); +assistantStreamExpect($done['event'] === 'done' && $done['data']['seq'] === 4, 'done is the terminal event'); +assistantStreamExpect($protocol->encode('error', ['message' => 'late']) === null, 'a second terminal event is rejected'); +assistantStreamExpect($protocol->encode('delta', ['text' => 'late']) === null, 'delta after terminal is rejected'); + +$errorProtocol = new AssistantSseProtocol(); +$errorProtocol->encode('start', []); +$error = parseAssistantSse((string) $errorProtocol->encode('error', [ + 'code' => 'AI_ASSISTANT_FAILED', + 'message' => 'AI 助手暂时不可用,请稍后重试', +])); +assistantStreamExpect($error['data']['seq'] === 2 && $errorProtocol->isTerminal(), 'error is the unique alternative terminal event'); + +$controller = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php'); +$logic = file_get_contents(dirname(__DIR__) . '/app/adminapi/logic/tcm/DiagnosisAiLogic.php'); +$validate = file_get_contents(dirname(__DIR__) . '/app/adminapi/validate/tcm/DiagnosisValidate.php'); +$auth = file_get_contents(dirname(__DIR__) . '/app/adminapi/http/middleware/AuthMiddleware.php'); +assistantStreamExpect(is_string($controller) && is_string($logic) && is_string($validate) && is_string($auth), 'stream implementation sources are readable'); + +$actionStart = strpos($controller, 'public function aiAssistantStream()'); +$checkAt = strpos($controller, "goCheck('aiAssistant')", $actionStart); +$prepareAt = strpos($controller, 'DiagnosisAiLogic::prepareAssistant(', $actionStart); +$runAt = strpos($controller, '$this->runAssistantSse($prepared)', $actionStart); +$headerAt = strpos($controller, "header('Content-Type: text/event-stream; charset=utf-8')", $actionStart); +assistantStreamExpect( + $actionStart !== false && $checkAt > $actionStart && $prepareAt > $checkAt && $runAt > $prepareAt && $headerAt > $runAt, + 'request validation and authorized preparation occur before every SSE header' +); +assistantStreamExpect( + str_contains($validate, "return \$this->only(['id', 'task', 'prompt']);"), + 'stream reuses the strict id/task/prompt assistant scene' +); +assistantStreamExpect( + str_contains($logic, 'self::PERMISSION_ASSISTANT') + && str_contains($logic, 'MyPatientLogic::canAccessDiagnosis') + && str_contains($logic, 'streamPreparedAssistant'), + 'stream preparation reuses assistant permission and canonical diagnosis row authorization' +); +assistantStreamExpect( + str_contains($auth, "\$accessUri === 'tcm.diagnosis/aiassistantstream'") + && str_contains($auth, "'tcm.diagnosis/aiassistant', \$adminUris"), + 'middleware maps stream access to the old registered assistant permission' +); +$matchPermissionAlias = (new ReflectionClass(AuthMiddleware::class))->getMethod('matchPermissionAlias'); +$authMiddleware = new AuthMiddleware(); +assistantStreamExpect( + $matchPermissionAlias->invoke( + $authMiddleware, + 'tcm.diagnosis/aiassistantstream', + ['tcm.diagnosis/aiassistant'] + ) === true, + 'stream permission alias accepts the old assistant grant' +); +assistantStreamExpect( + $matchPermissionAlias->invoke($authMiddleware, 'tcm.diagnosis/aiassistantstream', []) === false, + 'stream permission alias rejects an administrator without the old assistant grant' +); +assistantStreamExpect( + str_contains($controller, "'text' => \$delta") + && str_contains($controller, "'code' => 'AI_ASSISTANT_FAILED'") + && str_contains($controller, 'ignore_user_abort(true)') + && str_contains($controller, 'connection_aborted() === 1') + && !str_contains($controller, "DiagnosisAiLogic::getError()\n ]"), + 'delta carries text, disconnects abort upstream, and errors use a generic prompt-free payload' +); +assistantStreamExpect( + str_contains($logic, 'DifyChatService::chat(') + && str_contains($logic, 'DifyChatService::streamChat('), + 'legacy blocking and new streaming paths coexist' +); + +$sensitiveNeedles = ['api_key', 'base_url', 'query', 'inputs', 'user']; +foreach ($sensitiveNeedles as $needle) { + assistantStreamExpect(!array_key_exists($needle, $done['data']), "done event excludes internal {$needle}"); + assistantStreamExpect(!array_key_exists($needle, $error['data']), "error event excludes internal {$needle}"); +} + +echo "Diagnosis AI assistant stream contract: OK\n"; diff --git a/server/tests/DiagnosisWorkspaceRowAuthorizationTest.php b/server/tests/DiagnosisWorkspaceRowAuthorizationTest.php new file mode 100644 index 000000000..3c2052cf2 --- /dev/null +++ b/server/tests/DiagnosisWorkspaceRowAuthorizationTest.php @@ -0,0 +1,240 @@ +getFileName()); + if (!is_array($file)) { + throw new RuntimeException('authorization method source is readable'); + } + + return implode('', array_slice( + $file, + $method->getStartLine() - 1, + $method->getEndLine() - $method->getStartLine() + 1 + )); +} + +// Pure policy helpers are invoked directly so this security regression test never needs a real database. +$appointmentScope = (new ReflectionClass(AppointmentLogic::class)) + ->getMethod('appointmentRowManageableForScope'); +$filterPrescriptions = (new ReflectionClass(PrescriptionLogic::class)) + ->getMethod('filterViewablePrescriptions'); + +diagnosisWorkspaceAuthExpect( + $appointmentScope->invoke(null, 31, 41, 31, [1], null, false) === true, + 'assigned doctor can open the reception row' +); +diagnosisWorkspaceAuthExpect( + $appointmentScope->invoke(null, 32, 41, 31, [1], null, false) === false, + 'doctor cannot open another doctor appointment row' +); +diagnosisWorkspaceAuthExpect( + $appointmentScope->invoke(null, 32, 41, 41, [2], null, false) === true, + 'assigned assistant can open the reception row' +); +diagnosisWorkspaceAuthExpect( + $appointmentScope->invoke(null, 999, 999, 1, [1, 2], [], true) === true, + 'root keeps reception compatibility regardless of role and data scope' +); + +$ownPrescription = [ + 'id' => 51, + 'creator_id' => 7, + 'assistant_id' => 0, + 'is_shared' => 0, + 'visible_role_ids' => '', +]; +$otherPrescription = [ + 'id' => 52, + 'creator_id' => 8, + 'assistant_id' => 9, + 'is_shared' => 0, + 'visible_role_ids' => '', +]; +diagnosisWorkspaceAuthExpect( + $filterPrescriptions->invoke(null, [$ownPrescription], 7, []) === [$ownPrescription], + 'visible prescription keeps the existing response row unchanged' +); +diagnosisWorkspaceAuthExpect( + $filterPrescriptions->invoke(null, [$otherPrescription], 1, ['root' => 1]) === [$otherPrescription], + 'root keeps prescription compatibility' +); + +$diagnosisLogicSource = file_get_contents((new ReflectionClass(DiagnosisLogic::class))->getFileName()); +$diagnosisAiLogicSource = file_get_contents((new ReflectionClass(DiagnosisAiLogic::class))->getFileName()); +$myPatientLogicSource = file_get_contents((new ReflectionClass(MyPatientLogic::class))->getFileName()); +$appointmentLogicSource = file_get_contents((new ReflectionClass(AppointmentLogic::class))->getFileName()); +$prescriptionLogicSource = file_get_contents((new ReflectionClass(PrescriptionLogic::class))->getFileName()); +$diagnosisControllerSource = file_get_contents( + dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php' +); +$appointmentControllerSource = file_get_contents( + dirname(__DIR__) . '/app/adminapi/controller/doctor/AppointmentController.php' +); +$prescriptionControllerSource = file_get_contents( + dirname(__DIR__) . '/app/adminapi/controller/tcm/PrescriptionController.php' +); +$appointmentListsSource = file_get_contents( + dirname(__DIR__) . '/app/adminapi/lists/doctor/AppointmentLists.php' +); +$doctorNoteLogicSource = file_get_contents( + dirname(__DIR__) . '/app/adminapi/logic/doctor/DoctorNoteLogic.php' +); +foreach ([ + $diagnosisLogicSource, + $diagnosisAiLogicSource, + $myPatientLogicSource, + $appointmentLogicSource, + $prescriptionLogicSource, + $diagnosisControllerSource, + $appointmentControllerSource, + $prescriptionControllerSource, + $appointmentListsSource, + $doctorNoteLogicSource, +] as $source) { + diagnosisWorkspaceAuthExpect(is_string($source), 'authorization source is readable'); +} + +$myPatientScopeMethod = diagnosisWorkspaceMethodSource( + (new ReflectionClass(MyPatientLogic::class))->getMethod('applyScope') +); +$diagnosisReadonlyAuthMethod = diagnosisWorkspaceMethodSource( + (new ReflectionClass(DiagnosisLogic::class))->getMethod('canViewReadonlyDiagnosis') +); +$diagnosisAiAuthMethod = diagnosisWorkspaceMethodSource( + (new ReflectionClass(DiagnosisAiLogic::class))->getMethod('loadAuthorizedDiagnosis') +); +$prescriptionListMethod = diagnosisWorkspaceMethodSource( + (new ReflectionClass(PrescriptionLogic::class))->getMethod('listByDiagnosis') +); +$trackingWindowMethod = diagnosisWorkspaceMethodSource( + (new ReflectionClass(DiagnosisLogic::class))->getMethod('fetchTrackingWindow') +); +$trackingWindowControllerMethod = diagnosisWorkspaceMethodSource( + (new ReflectionClass(DiagnosisController::class))->getMethod('trackingWindow') +); +$doctorNotesControllerMethod = diagnosisWorkspaceMethodSource( + (new ReflectionClass(AppointmentController::class))->getMethod('doctorNotes') +); +$addDoctorNoteControllerMethod = diagnosisWorkspaceMethodSource( + (new ReflectionClass(AppointmentController::class))->getMethod('addDoctorNote') +); +$receptionMethod = diagnosisWorkspaceMethodSource( + (new ReflectionClass(AppointmentLogic::class))->getMethod('reception') +); +$prescriptionControllerMethod = diagnosisWorkspaceMethodSource( + (new ReflectionClass(PrescriptionController::class))->getMethod('listByDiagnosis') +); + +diagnosisWorkspaceAuthExpect( + str_contains($myPatientScopeMethod, 'in_array(self::ASSISTANT_ROLE_ID, $roleIds, true)') + && str_contains($myPatientScopeMethod, "'CAST(d.assistant_id AS UNSIGNED) = ' . \$adminId") + && str_contains($myPatientScopeMethod, 'in_array(self::DOCTOR_ROLE_ID, $roleIds, true)') + && str_contains($myPatientScopeMethod, 'scope_apt.doctor_id = {$adminId}'), + 'diagnosis row policy keeps assistant assignment and doctor appointment ownership contracts' +); +diagnosisWorkspaceAuthExpect( + str_contains($myPatientScopeMethod, 'array_intersect($roleIds, self::TEAM_ROLE_IDS)') + && str_contains($myPatientScopeMethod, 'DataScopeService::getVisibleAdminIds($adminId, $adminInfo)') + && strpos($myPatientScopeMethod, 'array_intersect($roleIds, self::TEAM_ROLE_IDS)') + < strpos($myPatientScopeMethod, 'in_array(self::DOCTOR_ROLE_ID, $roleIds, true)'), + 'DataScope ALL is reserved for team roles before ordinary doctor and assistant self-relations' +); +diagnosisWorkspaceAuthExpect( + str_contains($diagnosisReadonlyAuthMethod, 'MyPatientLogic::canAccessDiagnosis(') + && !str_contains($diagnosisReadonlyAuthMethod, 'DataScopeService::getVisibleAdminIds'), + 'readonly diagnosis authorization reuses the canonical patient row policy' +); +diagnosisWorkspaceAuthExpect( + str_contains($diagnosisAiAuthMethod, 'MyPatientLogic::canAccessDiagnosis(') + && strpos($diagnosisAiAuthMethod, 'MyPatientLogic::canAccessDiagnosis(') + < strpos($diagnosisAiAuthMethod, 'DiagnosisLogic::detail(') + && !str_contains($diagnosisAiAuthMethod, 'DataScopeService::getVisibleAdminIds'), + 'AI diagnosis authorization reuses the canonical row policy before loading case details' +); + +diagnosisWorkspaceAuthExpect( + str_contains($trackingWindowControllerMethod, 'canViewReadonlyDiagnosis((int) $params[\'id\']') + && strpos($trackingWindowControllerMethod, 'canViewReadonlyDiagnosis((int) $params[\'id\']') + < strpos($trackingWindowControllerMethod, 'DiagnosisLogic::fetchTrackingWindow('), + 'trackingWindow authorizes the diagnosis before reading tracking records' +); +diagnosisWorkspaceAuthExpect( + str_contains($trackingWindowMethod, "'diagnosis_id' => \$diagnosisId") + && strpos($trackingWindowMethod, "'diagnosis_id' => \$diagnosisId") + < strpos($trackingWindowMethod, "'blood_records'"), + 'trackingWindow returns the authorized diagnosis id at the response top level' +); +diagnosisWorkspaceAuthExpect( + str_contains($doctorNotesControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(') + && strpos($doctorNotesControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(') + < strpos($doctorNotesControllerMethod, 'DoctorNoteLogic::getByDiagnosis('), + 'doctorNotes authorizes the diagnosis before reading notes' +); +diagnosisWorkspaceAuthExpect( + str_contains($addDoctorNoteControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(') + && strpos($addDoctorNoteControllerMethod, 'DiagnosisLogic::canViewReadonlyDiagnosis(') + < strpos($addDoctorNoteControllerMethod, 'DoctorNoteLogic::addOrAppend('), + 'addDoctorNote authorizes the diagnosis before writing any note data' +); +diagnosisWorkspaceAuthExpect( + str_contains($receptionMethod, 'appointmentRowManageableByAdmin(') + && strpos($receptionMethod, 'appointmentRowManageableByAdmin(') + < strpos($receptionMethod, '$appointment = self::detail($params);'), + 'reception authorizes the appointment before loading its detail DTO' +); +diagnosisWorkspaceAuthExpect( + str_contains($prescriptionListMethod, 'MyPatientLogic::canAccessDiagnosis(') + && strpos($prescriptionListMethod, 'MyPatientLogic::canAccessDiagnosis(') + < strpos($prescriptionListMethod, "Prescription::where('diagnosis_id', \$diagnosisId)"), + 'listByDiagnosis authorizes its parent diagnosis before the first prescription SQL query' +); +diagnosisWorkspaceAuthExpect( + str_contains($prescriptionLogicSource, 'self::canViewPrescription($row, $viewerAdminId, $viewerAdminInfo)') + && str_contains( + $prescriptionControllerMethod, + 'PrescriptionLogic::listByDiagnosis($diagnosisId, (int) $this->adminId, $this->adminInfo)' + ) + && str_contains($prescriptionControllerMethod, "PrescriptionLogic::getError() !== ''"), + 'listByDiagnosis keeps child visibility filtering and surfaces parent authorization failure' +); +diagnosisWorkspaceAuthExpect( + str_contains($appointmentListsSource, 'u.patient_id AS source_patient_id'), + 'appointment DTO exposes the source patient id separately from the diagnosis id' +); +diagnosisWorkspaceAuthExpect( + str_contains($doctorNoteLogicSource, 'normalizeNewAttachmentPaths(') + && str_contains($doctorNoteLogicSource, "\$domainHost === \$urlHost") + && str_contains($doctorNoteLogicSource, "\$domainPort === \$urlPort") + && str_contains($doctorNoteLogicSource, "str_starts_with(\$urlPath, \$domainPath . '/')") + && str_contains($doctorNoteLogicSource, "str_starts_with(\$path, '//')"), + 'new note attachments require an exact configured storage origin and path boundary' +); +diagnosisWorkspaceAuthExpect( + substr_count($diagnosisControllerSource, '诊单不存在或无权访问') >= 2 + && str_contains($appointmentControllerSource, '预约记录不存在或无权访问') + && str_contains($appointmentControllerSource, '诊单不存在或无权访问'), + 'missing and forbidden child-resource lookups share non-enumerating errors' +); + +echo "Diagnosis workspace row authorization: OK\n"; diff --git a/server/tests/DifyChatStreamContractTest.php b/server/tests/DifyChatStreamContractTest.php new file mode 100644 index 000000000..664e9a2ab --- /dev/null +++ b/server/tests/DifyChatStreamContractTest.php @@ -0,0 +1,163 @@ +getMethod($method)->invokeArgs(null, $arguments); +} + +$generic = callDifyStreamPrivate('buildRequestSpecs', [ + 'https://ai.example.test/v1', + 'model-safe', + ['case' => 'redacted'], + 'safe query', + 'admin-safe', + true, +]); +difyStreamExpect(count($generic) === 2, 'generic /v1 keeps Dify then OpenAI fallback order'); +difyStreamExpect($generic[0]['protocol'] === 'dify', 'Dify remains the first generic protocol'); +difyStreamExpect( + $generic[0]['payload']['response_mode'] === 'streaming', + 'Dify stream request uses response_mode=streaming' +); +difyStreamExpect($generic[1]['protocol'] === 'openai', 'OpenAI remains the fallback protocol'); +difyStreamExpect($generic[1]['payload']['stream'] === true, 'OpenAI stream request uses stream=true'); +difyStreamExpect( + $generic[0]['payload']['inputs'] === ['case' => 'redacted'] + && $generic[0]['payload']['query'] === 'safe query' + && $generic[0]['payload']['user'] === 'admin-safe', + 'Dify streaming preserves structured inputs, query and user' +); + +$blocking = callDifyStreamPrivate('buildRequestSpecs', [ + 'https://ai.example.test/v1', + 'model-safe', + [], + 'safe query', + 'admin-safe', +]); +difyStreamExpect( + $blocking[0]['payload']['response_mode'] === 'blocking', + 'legacy Dify blocking request remains unchanged' +); +difyStreamExpect( + !array_key_exists('stream', $blocking[1]['payload']), + 'legacy OpenAI blocking request does not gain a stream field' +); + +$explicitDify = callDifyStreamPrivate('buildRequestSpecs', [ + 'https://ai.example.test/v1/chat-messages', 'model-safe', [], 'query', 'user', true, +]); +$explicitOpenAi = callDifyStreamPrivate('buildRequestSpecs', [ + 'https://ai.example.test/v1/chat/completions', 'model-safe', [], 'query', 'user', true, +]); +difyStreamExpect(count($explicitDify) === 1 && $explicitDify[0]['protocol'] === 'dify', 'explicit Dify endpoint never changes protocol'); +difyStreamExpect(count($explicitOpenAi) === 1 && $explicitOpenAi[0]['protocol'] === 'openai', 'explicit OpenAI endpoint never changes protocol'); + +$difyWire = ": ping\r\n\r\n" + . "data: {\"event\":\"message\",\"answer\":\"你\",\"message_id\":\"msg-safe\"}\r\n\r\n" + . "data: {\"event\":\"agent_message\",\"answer\":\"好\"}\r\n\r\n" + . "data: {\"event\":\"ping\"}\r\n\r\n" + . "data: {\"event\":\"message_end\",\"message_id\":\"msg-safe\"}\r\n\r\n"; +$difyChunks = str_split($difyWire, 1); +$decodedDify = callDifyStreamPrivate('decodeStreamChunks', ['dify', $difyChunks]); +difyStreamExpect($decodedDify['content'] === '你好', 'Dify decoder handles every possible byte boundary, including UTF-8 bytes'); +difyStreamExpect($decodedDify['deltas'] === ['你', '好'], 'Dify decoder emits only message text'); +difyStreamExpect($decodedDify['message_id'] === 'msg-safe', 'Dify decoder retains the safe message id internally'); +difyStreamExpect($decodedDify['finished'] === true, 'Dify message_end terminates parsing'); + +$openAiWire = "data: {\"id\":\"chat-safe\",\"choices\":[{\"delta\":{\"content\":\"A\"}}]}\n\n" + . "data: {\"choices\":[{\"delta\":{\"content\":\"中\"}}]}\n\n" + . "data: [DONE]"; +$decodedOpenAi = callDifyStreamPrivate('decodeStreamChunks', ['openai', str_split($openAiWire, 2)]); +difyStreamExpect($decodedOpenAi['content'] === 'A中', 'OpenAI decoder handles arbitrary byte chunks and final frame without newline'); +difyStreamExpect($decodedOpenAi['deltas'] === ['A', '中'], 'OpenAI decoder emits choices delta content only'); +difyStreamExpect($decodedOpenAi['finished'] === true, 'OpenAI [DONE] terminates parsing'); + +$malformed = callDifyStreamPrivate('decodeStreamChunks', [ + 'dify', + ["data: not-json\n\n", "data: {\"event\":\"error\",\"message\":\"secret-upstream-body\"}\n\n"], +]); +difyStreamExpect($malformed['content'] === '', 'malformed and upstream error frames never become text'); +difyStreamExpect($malformed['upstream_error'] === true, 'Dify error frame becomes an internal error flag'); +difyStreamExpect(!str_contains(json_encode($malformed), 'secret-upstream-body'), 'upstream error body is not retained'); + +$safeError = callDifyStreamPrivate('formatStreamResponse', [[ + 'errno' => 0, + 'http_code' => 200, + 'content' => '', + 'message_id' => '', + 'emitted' => false, + 'upstream_error' => true, + 'client_aborted' => false, + 'callback_error' => false, + 'finished' => false, +], microtime(true)]); +$encodedError = json_encode($safeError, JSON_UNESCAPED_UNICODE); +difyStreamExpect($safeError['error_code'] === 'UPSTREAM_REJECTED', 'upstream SSE errors map to a stable internal code'); +difyStreamExpect(!str_contains($encodedError, 'secret'), 'formatted stream errors contain no upstream body, key or prompt'); +$serviceSource = file_get_contents(dirname(__DIR__) . '/app/common/service/DifyChatService.php'); +difyStreamExpect( + is_string($serviceSource) + && str_contains($serviceSource, '$responseCode < 200 || $responseCode >= 300') + && str_contains($serviceSource, 'CURLOPT_HEADERFUNCTION => $header') + && str_contains($serviceSource, "'Accept: text/event-stream'") + && !str_contains($serviceSource, "config('ai')"), + 'streaming rejects HTTP error bodies before parsing and never mixes daily-diet AI configuration' +); + +$timeoutError = callDifyStreamPrivate('formatStreamResponse', [[ + 'errno' => CURLE_OPERATION_TIMEDOUT, + 'http_code' => 0, + 'content' => '', + 'message_id' => '', + 'emitted' => false, + 'upstream_error' => false, + 'client_aborted' => false, + 'callback_error' => false, + 'finished' => false, +], microtime(true)]); +$disconnectError = callDifyStreamPrivate('formatStreamResponse', [[ + 'errno' => CURLE_ABORTED_BY_CALLBACK, + 'http_code' => 200, + 'content' => 'partial prompt must not appear', + 'message_id' => '', + 'emitted' => true, + 'upstream_error' => false, + 'client_aborted' => true, + 'callback_error' => false, + 'finished' => false, +], microtime(true)]); +difyStreamExpect($timeoutError['error_code'] === 'UPSTREAM_TIMEOUT', 'curl timeout maps to a stable timeout result'); +difyStreamExpect($disconnectError['error_code'] === 'CLIENT_DISCONNECTED', 'client abort takes precedence over curl abort errno'); +difyStreamExpect(!str_contains(json_encode($disconnectError), 'partial prompt'), 'disconnect result does not echo partial content'); + +$incompleteError = callDifyStreamPrivate('formatStreamResponse', [[ + 'errno' => 0, + 'http_code' => 200, + 'content' => 'partial answer', + 'message_id' => '', + 'emitted' => true, + 'upstream_error' => false, + 'client_aborted' => false, + 'callback_error' => false, + 'finished' => false, +], microtime(true)]); +difyStreamExpect($incompleteError['error_code'] === 'INCOMPLETE_RESPONSE', 'missing [DONE]/message_end cannot become a successful done'); +difyStreamExpect(!str_contains(json_encode($incompleteError), 'partial answer'), 'incomplete response error does not echo partial content'); + +echo "Dify chat stream contract: OK\n"; diff --git a/server/tests/FirstVisitConversionFinanceAndChannelTest.php b/server/tests/FirstVisitConversionFinanceAndChannelTest.php new file mode 100644 index 000000000..40019c036 --- /dev/null +++ b/server/tests/FirstVisitConversionFinanceAndChannelTest.php @@ -0,0 +1,102 @@ + 'group:自媒体4', + 'channel_codes' => ['tag_et4h', 'tag_et4q', 'group:ignored'], + 'is_group' => true, +]); +conversionFinanceExpect( + $leafCodes === ['tag_et4h', 'tag_et4q'], + 'Stats channel codes must expand a group into leaf codes only' +); + +$reflection = new ReflectionClass(FirstVisitConversionLogic::class); +$canViewFinance = $reflection->getMethod('canViewFinance'); +$maskFinanceFields = $reflection->getMethod('maskFinanceFields'); +$personalYejiMediaSources = $reflection->getMethod('personalYejiMediaSources'); +$canViewFinance->setAccessible(true); +$maskFinanceFields->setAccessible(true); +$personalYejiMediaSources->setAccessible(true); + +conversionFinanceExpect( + $canViewFinance->invoke(null, 1, ['root' => 1, 'role_name' => '医助']) === true, + 'Root must always see cash cost and ROI' +); +conversionFinanceExpect( + $canViewFinance->invoke(null, 8, ['root' => 0, 'role_name' => '经理']) === true, + 'Managers must always see cash cost and ROI' +); +conversionFinanceExpect( + $canViewFinance->invoke(null, 0, ['root' => 0, 'role_name' => '诊室组长']) === false, + 'Group leaders without the finance permission must not see cash cost and ROI' +); +conversionFinanceExpect( + $canViewFinance->invoke(null, 0, ['root' => 0, 'role_name' => '医助']) === false, + 'Assistants without the finance permission must not see cash cost and ROI' +); + +$masked = $maskFinanceFields->invoke(null, [ + 'completed_order_count' => 2, + 'account_cost' => 88.5, + 'cash_cost' => 12.3, + 'roi' => 1.5, + 'children' => [[ + 'name' => '医助甲', + 'account_cost' => 40, + 'roi' => 2, + 'children' => [], + ]], +]); +conversionFinanceExpect( + !isset($masked['account_cost'], $masked['cash_cost'], $masked['roi']) + && $masked['completed_order_count'] === 2 + && !isset($masked['children'][0]['account_cost'], $masked['children'][0]['roi']), + 'Finance fields must be stripped from summary rows and nested members' +); + +$groupSources = $personalYejiMediaSources->invoke(null, 'group:自媒体4', [ + 'channel_code' => 'group:自媒体4', + 'channel_name' => '自媒体4', + 'channel_codes' => ['tag_et4h', 'tag_et4q'], + 'channel_names' => ['自媒体4H', '自媒体4Q'], + 'is_group' => true, +]); +conversionFinanceExpect( + is_array($groupSources) + && in_array('自媒体4', $groupSources, true) + && in_array('自媒体4H', $groupSources, true) + && in_array('自媒体4Q', $groupSources, true) + && in_array('tag_et4h', $groupSources, true) + && !in_array('group:自媒体4', $groupSources, true), + 'Group channel opening counts must match every leaf name and code, not the synthetic group code' +); + +echo "FirstVisitConversionFinanceAndChannelTest passed\n"; diff --git a/server/tests/MediaChannelExternalUserFilterTest.php b/server/tests/MediaChannelExternalUserFilterTest.php index e95020fd9..480ecdf61 100644 --- a/server/tests/MediaChannelExternalUserFilterTest.php +++ b/server/tests/MediaChannelExternalUserFilterTest.php @@ -23,8 +23,17 @@ $tagSql = (string)$tagQuery->fetchSql()->select(); if (!str_contains($tagSql, 'qywx_external_contact_tag')) { throw new RuntimeException('tag 渠道未使用结构化客户标签关系表'); } -if (!str_contains($tagSql, ' IN (SELECT channel_tag.external_userid')) { - throw new RuntimeException('tag 渠道未通过去重子查询过滤 external_userid'); +if (!str_contains($tagSql, 'EXISTS (SELECT 1 FROM')) { + throw new RuntimeException('tag 渠道未使用 EXISTS 半连接,避免物化整渠客户 ID'); +} +if (!str_contains($tagSql, 'channel_tag.external_userid = e.external_userid')) { + throw new RuntimeException('tag 渠道未按事实表 external_userid 相关查询'); +} +if (!str_contains($tagSql, 'tag_id = ')) { + throw new RuntimeException('单标签渠道应使用 tag_id = 走组合索引'); +} +if (str_contains($tagSql, 'tag_id IN (')) { + throw new RuntimeException('单标签渠道不应退化为 tag_id IN'); } if (str_contains($tagSql, 'follow_users') || str_contains($tagSql, 'LIKE')) { throw new RuntimeException('tag 渠道仍在扫描 follow_users JSON'); @@ -47,4 +56,26 @@ if (!str_contains($legacySql, 'channel_contact.delete_time IS NULL')) { throw new RuntimeException('老渠道回退包含了已删除客户记录'); } +$groupQuery = Db::name('qywx_external_contact_event')->alias('e'); +MediaChannelService::applyExternalUserChannelFilter( + $groupQuery, + 'e.external_userid', + [ + 'source_tag_id' => '', + 'source_tag_ids' => ['tag-group-a', 'tag-group-b'], + 'channel_name' => '自媒体4', + 'is_group' => true, + ] +); +$groupSql = (string)$groupQuery->fetchSql()->select(); +if (!str_contains($groupSql, 'EXISTS (SELECT 1 FROM')) { + throw new RuntimeException('分组渠道未使用 EXISTS 半连接'); +} +if (!str_contains($groupSql, 'tag_id IN (')) { + throw new RuntimeException('分组渠道未按多个 tag_id 过滤'); +} +if (str_contains($groupSql, 'follow_users') || str_contains($groupSql, 'LIKE')) { + throw new RuntimeException('分组渠道仍在扫描 follow_users JSON'); +} + echo "MEDIA_CHANNEL_EXTERNAL_USER_FILTER_OK\n";