Initial commit: Happy Up monorepo through Sprint 5.
Document-driven MVP with FastAPI backend, Vue H5, WeChat mini shell, product demo, and Docker dev stack. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"""Server-side PDF export for posture reports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
||||
|
||||
from app.db.models import Child, Report
|
||||
from app.services.reports import METRIC_LEVEL_LABELS, RISK_LABELS, report_to_dict
|
||||
|
||||
LEVEL_COLORS = {
|
||||
"normal": colors.HexColor("#2bb673"),
|
||||
"low": colors.HexColor("#84cc16"),
|
||||
"medium": colors.HexColor("#f59e0b"),
|
||||
"high": colors.HexColor("#ef4444"),
|
||||
}
|
||||
|
||||
|
||||
def build_report_pdf(report: Report, child: Child | None = None) -> bytes:
|
||||
payload = report_to_dict(report)
|
||||
buffer = BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buffer,
|
||||
pagesize=A4,
|
||||
leftMargin=18 * mm,
|
||||
rightMargin=18 * mm,
|
||||
topMargin=16 * mm,
|
||||
bottomMargin=16 * mm,
|
||||
title=f"Happy Up Report #{report.id}",
|
||||
)
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
title_style = ParagraphStyle(
|
||||
"ReportTitle",
|
||||
parent=styles["Heading1"],
|
||||
fontName="Helvetica-Bold",
|
||||
fontSize=18,
|
||||
textColor=colors.HexColor("#1a6fb5"),
|
||||
spaceAfter=8,
|
||||
)
|
||||
body = styles["BodyText"]
|
||||
muted = ParagraphStyle("Muted", parent=body, textColor=colors.HexColor("#6b7280"), fontSize=9)
|
||||
|
||||
child_name = child.name if child else f"儿童 #{report.child_id}"
|
||||
story = [
|
||||
Paragraph("儿童 AI 体态管理 · 筛查报告", title_style),
|
||||
Paragraph(f"儿童:{child_name} · 报告编号 #{report.id}", muted),
|
||||
Spacer(1, 8),
|
||||
]
|
||||
|
||||
if report.report_type == "movement_scoring":
|
||||
story.extend(_movement_sections(payload, body, muted))
|
||||
else:
|
||||
story.extend(_screening_sections(payload, body, muted))
|
||||
|
||||
story.append(Spacer(1, 10))
|
||||
story.append(Paragraph(payload.get("disclaimer", ""), muted))
|
||||
|
||||
doc.build(story)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _screening_sections(payload: dict, body, muted) -> list:
|
||||
risk = payload.get("riskLevel", "medium")
|
||||
risk_label = RISK_LABELS.get(risk, risk)
|
||||
sections = [
|
||||
Paragraph(f"<b>风险等级:</b>{risk_label}", body),
|
||||
Paragraph(f"<b>摘要:</b>{payload.get('summary', '')}", body),
|
||||
Spacer(1, 8),
|
||||
Paragraph("<b>指标明细</b>", body),
|
||||
]
|
||||
|
||||
rows = [["指标", "数值", "等级", "置信度"]]
|
||||
for metric in payload.get("metrics", []):
|
||||
rows.append(
|
||||
[
|
||||
metric.get("name", ""),
|
||||
f"{metric.get('value', 0)}%",
|
||||
METRIC_LEVEL_LABELS.get(metric.get("level", ""), metric.get("level", "")),
|
||||
f"{metric.get('confidence', 0):.0%}",
|
||||
]
|
||||
)
|
||||
|
||||
table = Table(rows, colWidths=[80, 60, 60, 60])
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#eef2f7")),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#1f2937")),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e5e7eb")),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#fafafa")]),
|
||||
]
|
||||
)
|
||||
)
|
||||
sections.append(table)
|
||||
sections.append(Spacer(1, 8))
|
||||
sections.append(Paragraph("<b>建议</b>", body))
|
||||
for idx, item in enumerate(payload.get("recommendations", []), start=1):
|
||||
sections.append(Paragraph(f"{idx}. {item}", body))
|
||||
return sections
|
||||
|
||||
|
||||
def _movement_sections(payload: dict, body, muted) -> list:
|
||||
score = payload.get("score", 0)
|
||||
sections = [
|
||||
Paragraph(f"<b>跟练得分:</b>{score}", body),
|
||||
Paragraph(
|
||||
f"完成 {payload.get('repsCompleted', 0)} 组 · "
|
||||
f"时长 {payload.get('durationSeconds', 0)} 秒",
|
||||
muted,
|
||||
),
|
||||
Spacer(1, 8),
|
||||
Paragraph("<b>维度评分</b>", body),
|
||||
]
|
||||
dims = payload.get("dimensions", {})
|
||||
rows = [["维度", "得分"]]
|
||||
for key, label in (
|
||||
("trajectory", "轨迹"),
|
||||
("angle", "角度"),
|
||||
("rhythm", "节奏"),
|
||||
("stability", "稳定性"),
|
||||
("completion", "完成度"),
|
||||
):
|
||||
rows.append([label, str(dims.get(key, "-"))])
|
||||
|
||||
table = Table(rows, colWidths=[120, 80])
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#eef2f7")),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e5e7eb")),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
]
|
||||
)
|
||||
)
|
||||
sections.append(table)
|
||||
return sections
|
||||
Reference in New Issue
Block a user