This commit is contained in:
Your Name
2026-08-12 14:48:57 +08:00
parent bc9ad1d3cd
commit 6d9fe1bbf8
403 changed files with 1790 additions and 1573 deletions
@@ -16,9 +16,20 @@ from datetime import date, datetime
from pathlib import Path
from typing import Any
from PySide6.QtCore import QBuffer, QByteArray, QDate, QIODevice, QPoint, Qt, QTimer, Signal
from PySide6.QtCore import (
QBuffer,
QByteArray,
QDate,
QIODevice,
QPoint,
QRectF,
Qt,
QTimer,
Signal,
)
from PySide6.QtGui import (
QColor,
QFont,
QImage,
QMouseEvent,
QPageSize,
@@ -2437,7 +2448,14 @@ def _status_text(value: Any) -> str:
def render_case_record_html(prescription: Any) -> str:
"""Render the immutable diagnosis snapshot stored with a prescription."""
"""Render the immutable diagnosis snapshot as the admin A3 case sheet.
``QTextDocument`` only implements a deliberately small HTML/CSS subset. In
particular, CSS grid and ``display: table-row`` silently degrade into a
vertical text stream. The admin preview uses a three-column grid, so this
version expresses that layout with real HTML tables which Qt renders
consistently on screen, printers and PDF devices.
"""
source = _mapping(prescription)
snapshot = _mapping(source.get("case_record"))
@@ -2452,11 +2470,35 @@ def render_case_record_html(prescription: Any) -> str:
value = json.dumps(value, ensure_ascii=False, default=str)
return html.escape(display_text(value, default))
def value(*keys: str, default: Any = None) -> Any:
for key in keys:
translated = case.get(f"{key}_text")
if translated not in (None, ""):
return translated
candidate = case.get(key)
if candidate not in (None, ""):
return candidate
return default
def present_date(raw: Any) -> Any:
return None if raw in (None, "", 0, "0", "0000-00-00") else raw
gender = case.get("gender", source.get("gender"))
gender_label = "" if gender in (1, "1") else "" if gender in (0, "0", 2, "2") else ""
marital = {0: "未婚", 1: "已婚", 2: "离异"}.get(_int(case.get("marital_status"), -1), "")
gender_label = (
case.get("gender_text")
or case.get("gender_desc")
or ("" if gender in (1, "1") else "" if gender in (0, "0", 2, "2") else "")
)
marital = case.get("marital_status_text") or {0: "未婚", 1: "已婚", 2: "离异"}.get(
_int(case.get("marital_status"), -1), ""
)
age = case.get("age") or source.get("age")
age_text = f"{age}" if age not in (None, "") and not str(age).endswith("") else age
diabetes_history = value("diabetes_discovery_year")
if diabetes_history not in (None, "") and str(diabetes_history).strip().isdigit():
diabetes_history = f"{str(diabetes_history).strip()}"
blood_pressure = ""
if case.get("systolic_pressure") not in (None, "") or case.get("diastolic_pressure") not in (
if case.get("systolic_pressure") not in (None, "") and case.get("diastolic_pressure") not in (
None,
"",
):
@@ -2465,102 +2507,173 @@ def render_case_record_html(prescription: Any) -> str:
f"{display_text(case.get('diastolic_pressure'))} mmHg"
)
def section(title: str, fields: Sequence[tuple[str, Any]]) -> str:
rows = "".join(
f'<div class="item"><span class="label">{html.escape(label)}</span>'
f'<span class="value">{esc(value)}</span></div>'
for label, value in fields
diagnosis_type = value("diagnosis_type_desc", "diagnosis_type")
diagnosis_type = _DIAGNOSIS_TYPE_LABELS.get(str(diagnosis_type or "").strip(), diagnosis_type)
def field_cell(label: str, field_value: Any, *, colspan: int = 1) -> str:
return (
f'<td class="cr-item" colspan="{colspan}">'
'<table class="cr-kv" width="100%" cellspacing="0" cellpadding="0"><tr>'
f'<td class="cr-label">{html.escape(label)}</td>'
f'<td class="cr-value">{esc(field_value)}</td>'
"</tr></table></td>"
)
def section(
title: str,
fields: Sequence[tuple[str, Any, bool]],
) -> str:
rows: list[str] = []
pending: list[str] = []
for label, field_value, full_width in fields:
if full_width:
if pending:
pending.extend('<td class="cr-item empty"></td>' for _ in range(3 - len(pending)))
rows.append(f'<tr>{"".join(pending)}</tr>')
pending = []
rows.append(f'<tr>{field_cell(label, field_value, colspan=3)}</tr>')
continue
pending.append(field_cell(label, field_value))
if len(pending) == 3:
rows.append(f'<tr>{"".join(pending)}</tr>')
pending = []
if pending:
pending.extend('<td class="cr-item empty"></td>' for _ in range(3 - len(pending)))
rows.append(f'<tr>{"".join(pending)}</tr>')
return (
'<table class="cr-section" width="100%" cellspacing="0" cellpadding="0">'
f'<tr><td class="cr-section-title">{html.escape(title)}</td></tr>'
'<tr><td><table class="cr-grid" width="100%" cellspacing="0" cellpadding="0">'
f'{"".join(rows)}</table></td></tr>'
'<tr><td class="cr-rule"></td></tr></table>'
)
return f'<section><h2>{html.escape(title)}</h2><div class="grid">{rows}</div></section>'
sections = [
section(
"基本信息",
(
("诊单ID", case.get("id") or case.get("diagnosis_id") or source.get("diagnosis_id")),
("姓名", case.get("patient_name") or source.get("patient_name")),
("身份证号", case.get("id_card")),
("手机号", case.get("phone") or source.get("phone")),
("性别", gender_label),
("年龄", case.get("age") or source.get("age")),
("婚姻状态", marital),
("身高", f"{case.get('height')} cm" if case.get("height") not in (None, "") else None),
("体重", f"{case.get('weight')} kg" if case.get("weight") not in (None, "") else None),
("地区", case.get("region")),
(
"诊单ID",
case.get("id") or case.get("diagnosis_id") or source.get("diagnosis_id"),
False,
),
("姓名", case.get("patient_name") or source.get("patient_name"), False),
("身份证号", case.get("id_card"), False),
("手机号", case.get("phone") or source.get("phone"), False),
("性别", gender_label, False),
("年龄", age_text, False),
("婚姻状态", marital, False),
(
"身高",
f"{case.get('height')} cm" if case.get("height") not in (None, "") else None,
False,
),
(
"体重",
f"{case.get('weight')} kg" if case.get("weight") not in (None, "") else None,
False,
),
("地区", case.get("region"), False),
),
),
section(
"生命体征",
(
("血压", blood_pressure),
("血压", blood_pressure, False),
(
"空腹血糖",
f"{case.get('fasting_blood_sugar')} mmol/L"
if case.get("fasting_blood_sugar") not in (None, "")
else None,
False,
),
),
),
section(
"主诉与诊断",
"主诉",
(
("诊断日期", case.get("diagnosis_date")),
("诊断类型", case.get("diagnosis_type_desc") or case.get("diagnosis_type")),
("证型", case.get("syndrome_type_desc") or case.get("syndrome_type")),
("糖尿病期数", case.get("diabetes_type_desc") or case.get("diabetes_type")),
("糖尿病病史", case.get("diabetes_discovery_year")),
("当地医院诊断", case.get("local_hospital_diagnosis")),
("当地就诊医院", case.get("local_hospital_name")),
("症状补充", case.get("symptoms") or case.get("clinical_diagnosis")),
("诊断日期", present_date(value("diagnosis_date")), False),
("诊断类型", diagnosis_type, False),
("证型", value("syndrome_type_desc", "syndrome_type"), False),
("糖尿病期数", value("diabetes_type_desc", "diabetes_type"), False),
("发现糖尿病病史", diabetes_history, False),
("当地医院诊断", value("local_hospital_diagnosis"), True),
("当地就诊医院", value("local_hospital_name"), True),
),
),
section(
"现病史",
(
("口腔感觉", case.get("appetite")),
("每日饮水量", case.get("water_intake")),
("体重变化", case.get("weight_change")),
("脂肪肝程度", case.get("fatty_liver_degree")),
("饮食情况", case.get("diet_condition")),
("肢体感觉", case.get("body_feeling")),
("睡眠情况", case.get("sleep_condition")),
("眼睛情况", case.get("eye_condition")),
("头部感觉", case.get("head_feeling")),
("出汗情况", case.get("sweat_condition")),
("皮肤情况", case.get("skin_condition")),
("小便情况", case.get("urine_condition")),
("大便情况", case.get("stool_condition")),
("腰肾情况", case.get("kidney_condition")),
("口腔感觉", value("appetite"), True),
("每日饮水量", value("water_intake"), True),
("体重变化", value("weight_change"), True),
("脂肪肝程度", value("fatty_liver_degree"), True),
("饮食情况", value("diet_condition"), True),
("肢体感觉", value("body_feeling"), True),
("睡眠情况", value("sleep_condition"), True),
("眼睛情况", value("eye_condition"), True),
("头部感觉", value("head_feeling"), True),
("出汗情况", value("sweat_condition"), True),
("皮肤情况", value("skin_condition"), True),
("小便情况", value("urine_condition"), True),
("大便情况", value("stool_condition"), True),
("腰肾情况", value("kidney_condition"), True),
("其他补充", case.get("symptoms") or case.get("clinical_diagnosis"), True),
),
),
section(
"病史与中医诊断",
"既往史",
(
("既往史", case.get("past_history")),
("外伤史", "" if _bool(case.get("trauma_history")) else ""),
("手术史", "" if _bool(case.get("surgery_history")) else ""),
("过敏史", "" if _bool(case.get("allergy_history")) else ""),
("家族病史", "" if _bool(case.get("family_history")) else ""),
("妊娠哺乳史", "" if _bool(case.get("pregnancy_history")) else ""),
("舌象", case.get("tongue_coating") or case.get("tongue")),
("脉象", case.get("pulse")),
("治则", case.get("treatment_principle")),
("医嘱", case.get("doctor_advice")),
("", value("past_history"), True),
),
),
section(
"其他病史",
(
("外伤史", "" if _bool(case.get("trauma_history")) else "", False),
("手术史", "" if _bool(case.get("surgery_history")) else "", False),
("过敏史", "" if _bool(case.get("allergy_history")) else "", False),
("家族病史", "" if _bool(case.get("family_history")) else "", False),
(
"妊娠哺乳史",
"" if _bool(case.get("pregnancy_history")) else "",
False,
),
),
),
section(
"诊断信息",
(
("舌象", case.get("tongue_coating") or case.get("tongue"), True),
("脉象", case.get("pulse"), True),
("治则", case.get("treatment_principle"), True),
("医嘱", case.get("doctor_advice"), True),
),
),
]
return f"""
<!doctype html><html><head><meta charset="utf-8"><style>
body {{ font-family:"Microsoft YaHei","PingFang SC",sans-serif; color:#17231d; margin:18px; }}
h1 {{ text-align:center; font-size:22px; margin:0 0 18px; }}
section {{ margin:0 0 16px; border:1px solid #bdc7c2; }}
h2 {{ font-size:15px; margin:0; padding:8px 10px; background:#edf2ef; }}
.grid {{ display:table; width:100%; }}
.item {{ display:table-row; }}
.label,.value {{ display:table-cell; padding:7px 10px; border-top:1px solid #d7dfdb; }}
.label {{ width:120px; color:#65736c; font-weight:600; }}
.value {{ line-height:1.6; }}
</style></head><body><h1>详细病历</h1>{''.join(sections)}</body></html>
body {{ margin:0; padding:12px; background:#eef1f5; color:#303133;
font-family:"Microsoft YaHei","PingFang SC","Segoe UI",sans-serif;
font-size:13px; }}
.paper {{ width:100%; margin:0 auto; background:#ffffff; border:1px solid #dcdfe6; }}
.paper-content {{ padding:26px 30px 34px; vertical-align:top; }}
h1 {{ text-align:center; font-size:20px; font-weight:600; margin:0;
padding:0 0 14px; border-bottom:2px solid #303133; }}
.cr-section {{ margin-top:14px; border-collapse:collapse; }}
.cr-section-title {{ padding:0 0 8px; color:#303133; font-size:15px; font-weight:600; }}
.cr-grid {{ table-layout:fixed; border-collapse:collapse; }}
.cr-item {{ width:33.33%; padding:4px 10px 5px 0; vertical-align:top; }}
.cr-item.empty {{ padding:0; }}
.cr-kv {{ border-collapse:collapse; table-layout:fixed; }}
.cr-label {{ width:88px; padding:0 7px 0 0; color:#909399; white-space:nowrap;
vertical-align:top; line-height:1.65; }}
.cr-value {{ padding:0; color:#303133; vertical-align:top; line-height:1.65; }}
.cr-rule {{ height:1px; border-bottom:1px solid #eeeeee; }}
</style></head><body>
<table class="paper" width="100%" cellspacing="0" cellpadding="0"><tr>
<td class="paper-content"><h1>甄养堂 详细病历</h1>{''.join(sections)}</td>
</tr></table></body></html>
"""
@@ -2568,18 +2681,20 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) -
"""Build the pharmacy-copy A4 slip used by preview, print and PDF export."""
source = _mapping(prescription)
paper_width = "100%" if print_layout else "820px"
paper_dimensions = 'width="100%"' if print_layout else 'width="820" height="1050"'
body_padding = "0" if print_layout else "10px"
body_background = "#ffffff" if print_layout else "#eef1f5"
# consumer/prescription/index.vue uses a strict 210 x 297 mm sheet. At
# Qt's 96 logical DPI this is 794 x 1123 px; keeping the HTML width fixed
# prevents QTextDocument from stretching the medicine columns with the
# containing dialog.
paper_width = "100%" if print_layout else "794px"
paper_dimensions = 'width="100%"' if print_layout else 'width="794" height="1123"'
body_padding = "0" if print_layout else "12px"
body_background = "#ffffff" if print_layout else "#f5f6f8"
paper_border = "0" if print_layout else "1px solid #d6d6d6"
base_font_size = "10px" if print_layout else "13px"
notice_font_size = "8.5px" if print_layout else "11px"
notice_font_size = "8.5px" if print_layout else "12px"
rp_font_size = "12px" if print_layout else "16px"
section_font_size = "9px" if print_layout else "12px"
watermark_height = "58px" if print_layout else "76px"
watermark_font_size = "41px" if print_layout else "54px"
bottom_height = "44px" if print_layout else "58px"
bottom_height = "44px" if print_layout else "70px"
herbs = _herb_rows(prescription)
dose_count = max(1, _int(source.get("dose_count"), 1))
main = [row for row in herbs if _formula(row.get("formula_type")) == "主方"]
@@ -2662,13 +2777,15 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) -
if not rows:
return ""
output = [
f'<tr><td class="rp-indent"></td><td class="section {kind}" colspan="4">'
f'<tr><td class="rp-indent"></td><td class="section {kind}" colspan="5">'
f"{html.escape(title)}</td></tr>"
]
for index in range(0, len(rows), 2):
pair = rows[index : index + 2]
cells: list[str] = []
for row in pair:
for pair_index, row in enumerate(pair):
if pair_index == 1:
cells.append('<td class="rp-gap"></td>')
dosage = number_text(row.get("dosage"))
total = number_text(_float(row.get("dosage")) * dose_count)
cells.extend(
@@ -2678,7 +2795,13 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) -
)
)
if len(pair) == 1:
cells.extend(('<td class="herb-name">&nbsp;</td>', '<td class="herb-total"></td>'))
cells.extend(
(
'<td class="rp-gap"></td>',
'<td class="herb-name">&nbsp;</td>',
'<td class="herb-total"></td>',
)
)
output.append(f'<tr><td class="rp-indent"></td>{"".join(cells)}</tr>')
return "".join(output)
@@ -2740,7 +2863,6 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) -
pharmacy_remark = (
source.get("pharmacy_remark")
or source.get("pharmacy_note")
or source.get("remark_extra")
)
explicit_out = (
@@ -2783,7 +2905,7 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) -
herb_html = herb_group(main, "主方", "main") + herb_group(aux, "辅方", "aux")
if not herb_html:
herb_html = (
'<tr><td class="rp-indent"></td><td class="empty-herbs" colspan="4">'
'<tr><td class="rp-indent"></td><td class="empty-herbs" colspan="5">'
"暂无药材明细</td></tr>"
)
text_rows = [f"<p>主方服法:{esc(main_usage_text)}</p>"]
@@ -2815,55 +2937,58 @@ def render_prescription_html(prescription: Any, *, print_layout: bool = False) -
return f"""
<!doctype html>
<html><head><meta charset="utf-8"><style>
body {{ margin:0; padding:{body_padding}; background:{body_background}; color:#151515;
font-family:"Microsoft YaHei","PingFang SC","Segoe UI",sans-serif;
font-size:{base_font_size}; }}
body {{ margin:0; padding:{body_padding}; background:{body_background}; color:#1f1f1f;
font-family:"Microsoft YaHei","PingFang SC",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
font-size:{base_font_size}; line-height:1.5; }}
.paper {{ width:{paper_width}; margin:0 auto; background:#ffffff;
border:{paper_border}; border-collapse:separate; border-spacing:0; }}
border:{paper_border}; border-collapse:separate; border-spacing:0;
box-shadow:{"none" if print_layout else "0 0 0 1px #d6d6d6"}; }}
.paper-content {{ padding:8mm 10mm; vertical-align:top; }}
.notice {{ width:100%; border:1px solid #e5e7eb; border-collapse:collapse;
table-layout:fixed; background:#f3f4f6; margin:0 0 6px;
font-size:{notice_font_size}; }}
font-size:{notice_font_size}; color:#1f1f1f; }}
.notice td {{ border:0; padding:6px 10px; }}
.notice-text {{ width:49%; }}
.notice-meta {{ width:51%; text-align:right; }}
.notice-meta {{ width:51%; text-align:right; white-space:nowrap; }}
.info {{ width:100%; border-collapse:collapse; table-layout:fixed; }}
.info td {{ border:1px solid #c8c8c8; padding:6px 10px; vertical-align:middle; }}
.info td {{ border:1px solid #c8c8c8; padding:6px 10px; vertical-align:middle; font-size:13px; }}
.info .full {{ border-top:0; }}
.key {{ white-space:nowrap; }}
.rp {{ width:100%; border-collapse:collapse; table-layout:fixed;
border-left:1px solid #c8c8c8; border-right:1px solid #c8c8c8; }}
.rp td {{ border:0; padding:3px 6px; vertical-align:middle; }}
.rp .rp-head td {{ border-bottom:1px solid #d4d4d4; padding-top:9px; padding-bottom:5px; }}
.rp-indent {{ width:34px; }}
.rp-mark {{ width:34px; font-size:{rp_font_size}; font-weight:700; }}
.key {{ white-space:nowrap; color:#1f1f1f; }}
.rp-frame {{ width:100%; border-collapse:collapse; table-layout:fixed;
border-left:1px solid #c8c8c8; border-right:1px solid #c8c8c8; }}
.rp-padding {{ padding:8px 10px 16px; }}
.rp {{ width:100%; border-collapse:collapse; table-layout:fixed; }}
.rp td {{ border:0; padding:3px 0; vertical-align:middle; }}
.rp .rp-head td {{ border-bottom:1px solid #d4d4d4; padding-top:4px; padding-bottom:4px; }}
.rp-indent {{ width:44px; }}
.rp-mark {{ width:44px; padding-right:8px !important;
font-size:{rp_font_size}; font-weight:700; color:#1f1f1f; }}
.drug-head {{ color:#1f1f1f; }}
.total-head, .herb-total {{ width:62px; text-align:right; white-space:nowrap; }}
.total-head, .herb-total {{ width:64px; text-align:right; white-space:nowrap;
font-variant-numeric:tabular-nums; }}
.rp-gap {{ width:24px; padding:0 !important; }}
.section {{ padding-top:7px !important; padding-bottom:1px !important;
font-size:{section_font_size}; font-weight:600; }}
.section.main {{ color:#409eff; }}
.section.aux {{ color:#e6a23c; }}
.herb-name {{ line-height:1.65; white-space:nowrap; }}
.herb-name {{ line-height:1.85; white-space:nowrap; color:#1f1f1f; }}
.empty-herbs {{ color:#8a8f98; padding:18px 6px !important; text-align:center; }}
.watermark {{ height:{watermark_height}; color:#eeeeee; text-align:center;
font-size:{watermark_font_size};
font-weight:700; letter-spacing:18px; vertical-align:middle !important; }}
.rx-text {{ border:1px solid #c8c8c8; border-top:0; padding:9px 12px; line-height:1.65; }}
.rx-text p {{ margin:1px 0; padding:0; }}
.rx-text {{ border:1px solid #c8c8c8; border-top:0; padding:10px 12px; line-height:1.85; font-size:13px; }}
.rx-text p {{ margin:0; padding:0; }}
.warning {{ color:#d72424; font-weight:600; }}
.bottom {{ width:100%; border-collapse:collapse; table-layout:fixed; }}
.bottom td {{ border:1px solid #c8c8c8; border-top:0; padding:7px 10px;
height:{bottom_height}; vertical-align:middle; }}
.bottom .doctor {{ width:22%; vertical-align:top; }}
.doctor-title {{ display:block; margin-bottom:4px; }}
.doctor-name {{ display:block; margin-top:8px; }}
.signature {{ max-width:130px; max-height:42px; vertical-align:middle; }}
.meta-key {{ white-space:nowrap; }}
.audit {{ width:{paper_width}; margin:8px auto 0; padding:8px 4px; color:#7b8492;
border-top:1px dashed #cfd5dd; font-size:12px; line-height:1.7; }}
.bottom td {{ border:1px solid #c8c8c8; border-top:0; padding:6px 10px;
height:{bottom_height}; vertical-align:middle; font-size:12px; color:#1f1f1f; }}
.bottom .doctor {{ width:28%; vertical-align:top; }}
.doctor-title {{ display:block; margin-bottom:4px; font-size:13px; }}
.doctor-name {{ display:block; margin-top:8px; font-size:13px; }}
.signature {{ max-width:110px; max-height:40px; vertical-align:middle; object-fit:contain; }}
.meta-key {{ white-space:nowrap; margin-right:6px; }}
.audit {{ width:{paper_width}; margin:12px auto 0; padding:8px 12px; color:#6b7280;
border:1px dashed #d4d4d4; background:#fafafa; border-radius:4px;
font-size:12px; line-height:1.6; }}
</style></head><body>
<table class="paper" {paper_dimensions}><tr><td class="paper-content" valign="top">
<table width="100%"><tr><td height="14"></td></tr></table>
<table align="center" class="paper" {paper_dimensions}><tr><td class="paper-content" valign="top">
<table class="notice" width="100%"><tr>
<td class="notice-text">服药前请核对姓名、电话、医生等信息以及服法、医嘱等要点</td>
<td class="notice-meta">日期:{esc(date_text())}  编号:{esc(serial)}</td>
@@ -2876,13 +3001,15 @@ body {{ margin:0; padding:{body_padding}; background:{body_background}; color:#1
<tr><td class="full" colspan="4"><span class="key">收件信息</span> {esc(recipient_text)}</td></tr>
<tr><td class="full" colspan="4"><span class="key">临床诊断</span> {esc(source.get("clinical_diagnosis"))}</td></tr>
</table>
<table class="rp-frame" title="药房联" width="100%"><tr><td class="rp-padding">
<table class="rp" width="100%">
<col width="34"/><col/><col width="62"/><col/><col width="62"/>
<col width="44"/><col/><col width="64"/><col width="24"/><col/><col width="64"/>
<tr class="rp-head"><td class="rp-mark">Rp.</td><td class="drug-head">用药 (单剂)</td>
<td class="total-head">总量</td><td class="drug-head">用药 (单剂)</td><td class="total-head">总量</td></tr>
<td class="total-head">总量</td><td class="rp-gap"></td>
<td class="drug-head">用药 (单剂)</td><td class="total-head">总量</td></tr>
{herb_html}
<tr><td class="rp-indent"></td><td class="watermark" colspan="4">药房联</td></tr>
</table>
</td></tr></table>
<div class="rx-text">{"".join(text_rows)}</div>
<table class="bottom" width="100%"><tr>
<td class="doctor"><span class="doctor-title">医师</span>{signature_html}</td>
@@ -2896,6 +3023,57 @@ body {{ margin:0; padding:{body_padding}; background:{body_background}; color:#1
"""
class _PrescriptionPaperPreview(QTextBrowser):
"""A4 prescription preview with the consumer page's floating watermark.
Qt rich text cannot paint CSS absolute-positioned or transformed elements,
so placing the watermark in the HTML makes it consume a table row and
deforms the paper. Paint it over the viewport at the corresponding
document position instead.
"""
_PAPER_WIDTH = 794
def __init__(self, prescription: Any, parent: QWidget | None = None) -> None:
super().__init__(parent)
herbs = _herb_rows(prescription)
main_count = sum(
1 for row in herbs if _formula(row.get("formula_type")) == "主方"
)
aux_count = len(herbs) - main_count
medicine_rows = (main_count + 1) // 2 + (aux_count + 1) // 2
section_count = int(main_count > 0) + int(aux_count > 0)
rp_height = 65 + medicine_rows * 30 + section_count * 22
self._watermark_document_y = 150 + rp_height // 2
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt override
super().paintEvent(event)
viewport = self.viewport()
center_y = self._watermark_document_y - self.verticalScrollBar().value()
if center_y < -90 or center_y > viewport.height() + 90:
return
paper_width = min(self._PAPER_WIDTH, viewport.width())
paper_left = max(0.0, (viewport.width() - paper_width) / 2)
painter = QPainter(viewport)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.translate(paper_left + paper_width / 2, center_y)
painter.rotate(-22)
painter.setPen(QColor(31, 31, 31, 15))
font = QFont(
"Microsoft YaHei UI",
-1,
QFont.Weight.Bold,
)
font.setPixelSize(84)
painter.setFont(font)
painter.drawText(
QRectF(-230, -70, 460, 140),
Qt.AlignmentFlag.AlignCenter,
"药房联",
)
painter.end()
class PrescriptionDetailDialog(QDialog):
diagnosis_requested = Signal(int)
orders_requested = Signal(int)
@@ -2937,12 +3115,12 @@ class PrescriptionDetailDialog(QDialog):
pdf_button.clicked.connect(self.choose_pdf_path)
actions.addWidget(pdf_button)
root.addLayout(actions)
self.preview = QTextBrowser()
self.preview = _PrescriptionPaperPreview(prescription)
self.preview.setObjectName("PrescriptionPaperPreview")
self.preview.setOpenExternalLinks(False)
self.preview.setStyleSheet(
"QTextBrowser#PrescriptionPaperPreview {"
"background-color:#EEF1F5; border:1px solid #D8DEE8; padding:0;}"
"background-color:#F5F6F8; border:1px solid #D8DEE8; padding:0;}"
)
self.preview.setDocument(self.document)
self.tabs = QTabWidget()
@@ -2952,8 +3130,15 @@ class PrescriptionDetailDialog(QDialog):
self.case_preview: QTextBrowser | None = None
if case_record:
self.case_document = QTextDocument(self)
self.case_document.setDocumentMargin(0)
self.case_document.setHtml(render_case_record_html(prescription))
self.case_preview = QTextBrowser()
self.case_preview.setObjectName("CaseRecordPaperPreview")
self.case_preview.setOpenExternalLinks(False)
self.case_preview.setStyleSheet(
"QTextBrowser#CaseRecordPaperPreview {"
"background-color:#EEF1F5; border:1px solid #D8DEE8; padding:0;}"
)
self.case_preview.setDocument(self.case_document)
case_index = self.tabs.addTab(self.case_preview, "详细病历")
if initial_tab == "case":
+225 -26
View File
@@ -8,8 +8,8 @@ from datetime import date, timedelta
from pathlib import Path
from typing import Any
from PySide6.QtCore import Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QDesktopServices, QPixmap, QTextCursor
from PySide6.QtCore import QSize, Qt, QTimer, QUrl, Signal
from PySide6.QtGui import QDesktopServices, QIcon, QPixmap, QTextCursor
from PySide6.QtWidgets import (
QDialog,
QFileDialog,
@@ -139,6 +139,73 @@ def _is_local_material_reference(value: str) -> bool:
)
class _NoteAttachmentPreview(QPushButton):
"""Responsive inline thumbnail that remains clickable for a full preview."""
_THUMBNAIL_HEIGHT = 132
def __init__(self, caption: str, parent: QWidget | None = None) -> None:
super().__init__("正在加载图片…", parent)
self._source_pixmap = QPixmap()
self.setObjectName("NoteAttachmentPreview")
self.setProperty("variant", "secondary")
self.setProperty("loadState", "loading")
self.setAccessibleName(f"查看{caption}大图")
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setMinimumWidth(168)
self.setFixedHeight(self._THUMBNAIL_HEIGHT)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
def _refresh_preview(self) -> None:
if self._source_pixmap.isNull():
return
available = QSize(max(120, self.width() - 12), self._THUMBNAIL_HEIGHT - 12)
rendered = self._source_pixmap.scaled(
available,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
self.setIcon(QIcon(rendered))
self.setIconSize(rendered.size())
def show_loading(self) -> None:
self._source_pixmap = QPixmap()
self.setIcon(QIcon())
self.setText("正在加载图片…")
self.setProperty("loadState", "loading")
self.setEnabled(False)
def show_preview(self, pixmap: QPixmap) -> None:
"""Render the decoded image directly in the note card."""
self._source_pixmap = pixmap.scaled(
QSize(960, 720),
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
self.setText("")
self._refresh_preview()
self.setProperty("loadState", "ready")
self.setEnabled(True)
self.style().unpolish(self)
self.style().polish(self)
def show_failure(self) -> None:
"""Keep a failed attachment actionable so the user can retry it."""
self._source_pixmap = QPixmap()
self.setIcon(QIcon())
self.setText("图片加载失败\n点击重试")
self.setProperty("loadState", "failed")
self.setEnabled(True)
self.style().unpolish(self)
self.style().polish(self)
def resizeEvent(self, event: Any) -> None: # noqa: N802 - Qt override
super().resizeEvent(event)
self._refresh_preview()
class QueueRow(QWidget):
"""Compact appointment summary rendered inside the queue list."""
@@ -232,6 +299,7 @@ class ReceptionPage(QWidget):
self._pending_report_files: list[str] = []
self._note_busy = False
self._attachment_preview_generation = 0
self._attachment_render_generation = 0
self._can_complete = has_permission(self.permissions, "doctor.appointment/complete")
self._can_note = has_permission(self.permissions, "doctor.appointment/addDoctorNote")
@@ -1238,6 +1306,8 @@ class ReceptionPage(QWidget):
self.daily_text.setText("\n".join(lines) if lines else "近 30 日暂无日常记录。")
def _render_notes(self, notes: list[Any]) -> None:
self._attachment_render_generation += 1
render_generation = self._attachment_render_generation
clear_layout(self.notes_layout)
self.notes_count.setText(f"{len(notes)}")
if not notes:
@@ -1262,44 +1332,159 @@ class ReceptionPage(QWidget):
meta.setProperty("role", "muted")
layout.addWidget(meta)
note_id = _as_int(first_value(note, "id", "note_id", default=None))
image_attachments: list[tuple[str, str, str]] = []
file_attachments: list[tuple[str, str, str]] = []
for image_type, caption in (
("tongue_images", "舌苔图"),
("report_files", "检查报告"),
):
for path in _sequence(first_value(note, image_type, default=[])):
path_text = str(path).strip()
attachment = QWidget()
attachment_layout = QHBoxLayout(attachment)
attachment_layout.setContentsMargins(0, 0, 0, 0)
label = QLabel(f"{caption}{_attachment_name(path_text)}")
label.setToolTip(path_text)
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
attachment_layout.addWidget(label, 1)
preview_button = QPushButton(
"预览" if _is_image_attachment(path_text) else "打开"
)
preview_button.setProperty("variant", "secondary")
preview_button.setEnabled(bool(path_text))
preview_button.clicked.connect(
lambda _checked=False, current_path=path_text, button=preview_button: (
attachment = (image_type, caption, path_text)
if _is_image_attachment(path_text):
image_attachments.append(attachment)
else:
file_attachments.append(attachment)
if image_attachments:
image_title = QLabel("图片附件 · 点击图片查看大图")
image_title.setProperty("role", "muted")
layout.addWidget(image_title)
image_host = QWidget()
image_grid = QGridLayout(image_host)
image_grid.setContentsMargins(0, 0, 0, 0)
image_grid.setHorizontalSpacing(10)
image_grid.setVerticalSpacing(10)
for index, (image_type, caption, path_text) in enumerate(
image_attachments
):
tile = QFrame()
tile.setObjectName("NoteAttachmentTile")
tile_layout = QVBoxLayout(tile)
tile_layout.setContentsMargins(8, 8, 8, 8)
tile_layout.setSpacing(6)
preview = _NoteAttachmentPreview(caption)
preview.setToolTip(f"{caption}{path_text}\n点击查看大图")
preview.clicked.connect(
lambda _checked=False, current_path=path_text, button=preview: (
self._preview_note_attachment(current_path, button)
)
)
attachment_layout.addWidget(preview_button)
tile_layout.addWidget(preview)
footer = QHBoxLayout()
footer.setSpacing(6)
label = QLabel(f"{caption} · {_attachment_name(path_text)}")
label.setObjectName("NoteAttachmentName")
label.setToolTip(path_text)
label.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
)
label.setMinimumWidth(0)
label.setSizePolicy(
QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred
)
footer.addWidget(label, 1)
if self._can_note and note_id is not None:
delete_button = QPushButton("删除")
delete_button.setProperty("variant", "secondary")
delete_button.clicked.connect(
lambda _checked=False, current_note_id=note_id, current_type=image_type, current_path=str(path), button=delete_button: (
lambda _checked=False, current_note_id=note_id, current_type=image_type, current_path=path_text, button=delete_button: (
self._delete_note_attachment(
current_note_id, current_type, current_path, button
)
)
)
attachment_layout.addWidget(delete_button)
layout.addWidget(attachment)
footer.addWidget(delete_button)
tile_layout.addLayout(footer)
row, column = divmod(index, 3)
image_grid.addWidget(tile, row, column)
self._load_note_thumbnail(path_text, preview, render_generation)
for column in range(3):
image_grid.setColumnStretch(column, 1)
layout.addWidget(image_host)
for image_type, caption, path_text in file_attachments:
attachment = QWidget()
attachment_layout = QHBoxLayout(attachment)
attachment_layout.setContentsMargins(0, 0, 0, 0)
label = QLabel(f"{caption}{_attachment_name(path_text)}")
label.setToolTip(path_text)
label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
attachment_layout.addWidget(label, 1)
open_button = QPushButton("打开")
open_button.setProperty("variant", "secondary")
open_button.setEnabled(bool(path_text))
open_button.clicked.connect(
lambda _checked=False, current_path=path_text, button=open_button: (
self._preview_note_attachment(current_path, button)
)
)
attachment_layout.addWidget(open_button)
if self._can_note and note_id is not None:
delete_button = QPushButton("删除")
delete_button.setProperty("variant", "secondary")
delete_button.clicked.connect(
lambda _checked=False, current_note_id=note_id, current_type=image_type, current_path=path_text, button=delete_button: (
self._delete_note_attachment(
current_note_id, current_type, current_path, button
)
)
)
attachment_layout.addWidget(delete_button)
layout.addWidget(attachment)
self.notes_layout.addWidget(card)
def _load_note_thumbnail(
self,
path: str,
preview: _NoteAttachmentPreview,
generation: int,
) -> None:
"""Download and paint a note image without opening another window."""
download = getattr(self.repository, "download_public_image", None)
if not callable(download):
preview.show_failure()
return
run_async(
lambda: invoke(self.repository, "download_public_image", url=path),
on_success=lambda payload: self._apply_note_thumbnail(
payload, preview, generation
),
on_error=lambda _error: self._fail_note_thumbnail(preview, generation),
)
def _apply_note_thumbnail(
self,
payload: Any,
preview: _NoteAttachmentPreview,
generation: int,
) -> None:
if generation != self._attachment_render_generation:
return
content = bytes(payload or b"")
pixmap = QPixmap()
if (
not content
or len(content) > 10 * 1024 * 1024
or not pixmap.loadFromData(content)
or pixmap.isNull()
):
self._fail_note_thumbnail(preview, generation)
return
with suppress(RuntimeError):
preview.show_preview(pixmap)
def _fail_note_thumbnail(
self,
preview: _NoteAttachmentPreview,
generation: int,
) -> None:
if generation != self._attachment_render_generation:
return
with suppress(RuntimeError):
preview.show_failure()
def _preview_note_attachment(self, path: str, button: QPushButton) -> None:
"""Preview note images in-app and open non-image reports safely."""
@@ -1325,8 +1510,11 @@ class ReceptionPage(QWidget):
return
self._attachment_preview_generation += 1
generation = self._attachment_preview_generation
button.setEnabled(False)
button.setText("加载中…")
if isinstance(button, _NoteAttachmentPreview):
button.show_loading()
else:
button.setEnabled(False)
button.setText("加载中…")
run_async(
lambda: invoke(self.repository, "download_public_image", url=target),
on_success=lambda payload: self._show_note_image_preview(
@@ -1346,13 +1534,21 @@ class ReceptionPage(QWidget):
) -> None:
if generation != self._attachment_preview_generation:
return
button.setEnabled(True)
button.setText("预览")
content = bytes(payload or b"")
pixmap = QPixmap()
if not content or len(content) > 10 * 1024 * 1024 or not pixmap.loadFromData(content):
if isinstance(button, _NoteAttachmentPreview):
button.show_failure()
else:
button.setEnabled(True)
button.setText("预览")
show_toast(self, "服务器返回的图片无法预览。", "danger", 4600)
return
if isinstance(button, _NoteAttachmentPreview):
button.show_preview(pixmap)
else:
button.setEnabled(True)
button.setText("预览")
dialog = QDialog(self)
dialog.setWindowTitle(f"预览 · {_attachment_name(path)}")
dialog.setModal(True)
@@ -1387,8 +1583,11 @@ class ReceptionPage(QWidget):
) -> None:
if generation != self._attachment_preview_generation:
return
button.setEnabled(True)
button.setText("预览")
if isinstance(button, _NoteAttachmentPreview):
button.show_failure()
else:
button.setEnabled(True)
button.setText("预览")
show_toast(self, f"图片预览失败:{friendly_error(error)}", "danger", 5200)
def _reset_detail_content(self, seed: Any = None) -> None:
+27
View File
@@ -205,6 +205,33 @@ QPushButton[variant="secondary"]:checked {
border-color: $indigo_pressed;
}
QFrame#NoteAttachmentTile {
background-color: $surface;
border: 1px solid $line;
border-radius: 10px;
}
QPushButton#NoteAttachmentPreview {
min-height: 132px;
padding: 6px;
color: $muted;
background-color: $surface_alt;
border: 1px solid $line;
border-radius: 8px;
font-weight: 500;
}
QPushButton#NoteAttachmentPreview:hover {
background-color: $indigo_pale;
border-color: $indigo_hover;
}
QPushButton#NoteAttachmentPreview[loadState="failed"] {
color: $danger;
background-color: $danger_pale;
}
QLabel#NoteAttachmentName {
color: $text_soft;
font-size: 12px;
}
QPushButton[variant="danger"] {
color: $danger;
background-color: $danger_pale;
+12 -1
View File
@@ -423,6 +423,8 @@ def test_order_payload_and_a4_print_document(
assert "黄芪" in rendered
assert "酸枣仁" in rendered
assert "服药前请核对姓名、电话、医生等信息" in rendered
assert '<table align="center" class="paper" width="794" height="1123"' in rendered
assert '<col width="44"/><col/><col width="64"/><col width="24"/>' in rendered
assert "Rp." in rendered
assert "药房联" in rendered
assert "主方" in rendered
@@ -433,8 +435,9 @@ def test_order_payload_and_a4_print_document(
assert "每天2次, 一次2袋, 每袋5g, 温水送服, 饭后" in rendered
viewer = PrescriptionDetailDialog(prescription)
document_html = viewer.document.toHtml()
assert "药房联" in document_html
assert "用药 (单剂)" in document_html
assert viewer.document.idealWidth() == pytest.approx(794.0)
assert viewer.preview.__class__.__name__ == "_PrescriptionPaperPreview"
viewer.close()
order.close()
application.processEvents()
@@ -450,6 +453,7 @@ def test_prescription_detail_can_open_immutable_case_record_tab(
"case_record": {
"patient_name": "林晓岚",
"phone": "13800138000",
"diagnosis_type": "follow_up",
"local_hospital_name": "市中医院",
"symptoms": "口渴乏力",
"tongue_coating": "舌红少苔",
@@ -461,8 +465,15 @@ def test_prescription_detail_can_open_immutable_case_record_tab(
assert viewer.tabs.tabText(viewer.tabs.currentIndex()) == "详细病历"
assert viewer.case_document is not None
case_html = viewer.case_document.toHtml()
source_html = dialog_module.render_case_record_html(prescription)
assert "甄养堂 详细病历" in case_html
assert '<table class="cr-grid"' in source_html
assert "follow_up" not in case_html
assert "复诊" in case_html
assert "市中医院" in case_html
assert "口渴乏力" in case_html
assert viewer.case_preview is not None
assert viewer.case_preview.objectName() == "CaseRecordPaperPreview"
viewer.close()
application.processEvents()
+8 -2
View File
@@ -69,8 +69,9 @@ def test_note_attachment_preview_type_is_extension_aware(path: str, expected: bo
assert _is_image_attachment(path) is expected
def test_note_attachments_offer_image_preview_and_file_open(
def test_note_attachments_expand_images_and_keep_file_open(
application: QApplication,
immediate_async: None,
) -> None:
page = ReceptionPage(DemoDoctorRepository(), PermissionSet([]))
page._render_notes(
@@ -87,8 +88,13 @@ def test_note_attachments_offer_image_preview_and_file_open(
]
)
previews = page.notes_container.findChildren(
QPushButton, "NoteAttachmentPreview"
)
assert len(previews) == 2
assert all(button.text() == "" for button in previews)
assert all(not button.icon().isNull() for button in previews)
labels = [button.text() for button in page.notes_container.findChildren(QPushButton)]
assert labels.count("预览") == 2
assert labels.count("打开") == 1
page.close()
application.processEvents()