Files
Shixun/xuqiu/scripts/generate_requirements_ppt.py
T
2026-07-31 12:27:41 +08:00

1053 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""生成《智慧医院 AI 影像诊断与电子病历辅助决策系统》需求答辩 PPT。"""
from __future__ import annotations
from pathlib import Path
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_CONNECTOR, MSO_SHAPE
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.oxml.ns import qn
from pptx.util import Emu, Inches, Pt
# ---------- 主题 ----------
DARK = RGBColor(0x0B, 0x3A, 0x5C)
TEAL = RGBColor(0x1A, 0xBC, 0x9C)
TEAL_DARK = RGBColor(0x12, 0x8A, 0x72)
LIGHT = RGBColor(0xF5, 0xF8, 0xFB)
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
GRAY = RGBColor(0x5A, 0x6A, 0x7A)
GRAY_LIGHT = RGBColor(0xE8, 0xEE, 0xF3)
ORANGE = RGBColor(0xE6, 0x7E, 0x22)
RED_SOFT = RGBColor(0xC0, 0x39, 0x2B)
CARD_BG = RGBColor(0xFF, 0xFF, 0xFF)
ROW_ALT = RGBColor(0xEE, 0xF6, 0xF4)
FONT = "微软雅黑"
SLIDE_W = Inches(13.333)
SLIDE_H = Inches(7.5)
OUT_PATH = Path(__file__).resolve().parent.parent / "智慧医院-需求答辩PPT.pptx"
MEMBERS = [
("成员 A", "项目负责人 / 需求分析", "需求梳理、文档、答辩统筹、验收", "全局 P0–P2", "需求规格、答辩 PPT、演示剧本"),
("成员 B", "前端负责人", "布局/路由守卫/仪表盘/通用组件", "F-01/06/08/14/16", "页面路由、ECharts、交互体验"),
("成员 C", "前端业务开发", "患者/预约/病历/影像结果展示", "F-02/03/05/07", "业务页、360°档案、报告弹窗"),
("成员 D", "业务后端负责人", "JWT、权限、统一响应、用户管理", "F-01/06/11/12", "REST、Security、种子账号"),
("成员 E", "后端业务 + 数据", "CRUD、异步诊断编排、降级", "F-02–05/07/13", "JPA、状态机、AI 客户端"),
("成员 F", "AI 微服务", "FastAPI、YOLO、RAG/LLM、知识库", "F-04/05/09–12", "检测报告、决策、权重管理"),
]
def set_run_font(run, size=14, bold=False, color=DARK, font_name=FONT):
run.font.size = Pt(size)
run.font.bold = bold
run.font.color.rgb = color
run.font.name = font_name
rPr = run._r.get_or_add_rPr()
ea = rPr.get_or_add_ea() if hasattr(rPr, "get_or_add_ea") else None
# east asian font
from lxml import etree
for child in list(rPr):
if child.tag.endswith("}ea") or child.tag.endswith("}cs"):
pass
ea_elem = rPr.find(qn("a:ea"))
if ea_elem is None:
ea_elem = etree.SubElement(rPr, qn("a:ea"))
ea_elem.set("typeface", font_name)
def add_textbox(slide, left, top, width, height, text, size=14, bold=False, color=DARK, align=PP_ALIGN.LEFT, font_name=FONT):
box = slide.shapes.add_textbox(left, top, width, height)
tf = box.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = align
run = p.add_run()
run.text = text
set_run_font(run, size=size, bold=bold, color=color, font_name=font_name)
return box
def add_paragraph(tf, text, size=13, bold=False, color=DARK, align=PP_ALIGN.LEFT, space_before=0, space_after=4):
p = tf.add_paragraph() if tf.paragraphs[0].text or len(tf.paragraphs) > 1 or (tf.paragraphs[0].runs) else tf.paragraphs[0]
if p.text and p.runs:
p = tf.add_paragraph()
p.alignment = align
p.space_before = Pt(space_before)
p.space_after = Pt(space_after)
run = p.add_run()
run.text = text
set_run_font(run, size=size, bold=bold, color=color)
return p
def fill_shape(shape, color: RGBColor):
shape.fill.solid()
shape.fill.fore_color.rgb = color
shape.line.fill.background()
def add_rect(slide, left, top, width, height, fill=TEAL, line=None):
shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, width, height)
fill_shape(shape, fill)
if line is not None:
shape.line.color.rgb = line
shape.line.width = Pt(1)
else:
shape.line.fill.background()
# softer corners
try:
shape.adjustments[0] = 0.08
except Exception:
pass
return shape
def add_rect_sharp(slide, left, top, width, height, fill=DARK):
shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, top, width, height)
fill_shape(shape, fill)
return shape
def set_shape_text(shape, text, size=12, bold=False, color=WHITE, align=PP_ALIGN.CENTER, valign=MSO_ANCHOR.MIDDLE):
tf = shape.text_frame
tf.word_wrap = True
tf.auto_size = None
tf.paragraphs[0].alignment = align
shape.text_frame.paragraphs[0].clear() if False else None
p = tf.paragraphs[0]
p.alignment = align
# clear existing
for r in list(p.runs):
r.text = ""
if p.runs:
run = p.runs[0]
run.text = text
else:
run = p.add_run()
run.text = text
set_run_font(run, size=size, bold=bold, color=color)
tf.paragraphs[0].alignment = align
try:
tf._txBody.bodyPr.set("anchor", "ctr" if valign == MSO_ANCHOR.MIDDLE else "t")
except Exception:
pass
def clear_and_set_text(shape, lines, size=12, bold=False, color=WHITE, align=PP_ALIGN.CENTER):
"""lines: str or list of (text, size, bold, color)."""
tf = shape.text_frame
tf.word_wrap = True
if isinstance(lines, str):
lines = [(lines, size, bold, color)]
# first paragraph
p0 = tf.paragraphs[0]
p0.alignment = align
# remove extra paragraphs
for i, item in enumerate(lines):
if len(item) == 4:
t, s, b, c = item
else:
t = item[0]
s = item[1] if len(item) > 1 else size
b = item[2] if len(item) > 2 else bold
c = item[3] if len(item) > 3 else color
if i == 0:
p = p0
if p.runs:
p.runs[0].text = t
set_run_font(p.runs[0], size=s, bold=b, color=c)
for extra in p.runs[1:]:
extra.text = ""
else:
run = p.add_run()
run.text = t
set_run_font(run, size=s, bold=b, color=c)
else:
p = tf.add_paragraph()
p.alignment = align
run = p.add_run()
run.text = t
set_run_font(run, size=s, bold=b, color=c)
try:
tf._txBody.bodyPr.set("anchor", "ctr")
except Exception:
pass
def add_footer(slide, page_no, total=18):
add_rect_sharp(slide, 0, Inches(7.15), SLIDE_W, Inches(0.35), fill=DARK)
add_textbox(
slide,
Inches(0.4),
Inches(7.18),
Inches(8),
Inches(0.28),
"智慧医院 · 需求答辩 | 仅供教学实训演示",
size=10,
color=WHITE,
)
add_textbox(
slide,
Inches(11.2),
Inches(7.18),
Inches(1.7),
Inches(0.28),
f"{page_no} / {total}",
size=10,
color=WHITE,
align=PP_ALIGN.RIGHT,
)
def add_header_bar(slide, title, subtitle=None):
add_rect_sharp(slide, 0, 0, SLIDE_W, Inches(0.95), fill=DARK)
add_rect_sharp(slide, 0, Inches(0.95), SLIDE_W, Inches(0.08), fill=TEAL)
add_textbox(slide, Inches(0.45), Inches(0.22), Inches(12), Inches(0.45), title, size=24, bold=True, color=WHITE)
if subtitle:
add_textbox(slide, Inches(0.45), Inches(0.58), Inches(12), Inches(0.3), subtitle, size=11, color=TEAL)
def add_bg(slide):
add_rect_sharp(slide, 0, 0, SLIDE_W, SLIDE_H, fill=LIGHT)
def style_table(table, header=True, col_widths=None):
for r_idx, row in enumerate(table.rows):
for c_idx, cell in enumerate(row.cells):
cell.vertical_anchor = MSO_ANCHOR.MIDDLE
# background
tc = cell._tc
tcPr = tc.get_or_add_tcPr()
# remove existing solid fill
for child in list(tcPr):
if child.tag.endswith("}solidFill") or child.tag.endswith("}//a:solidFill"):
tcPr.remove(child)
from lxml import etree
solid = etree.SubElement(tcPr, qn("a:solidFill"))
srgb = etree.SubElement(solid, qn("a:srgbClr"))
if header and r_idx == 0:
srgb.set("val", "0B3A5C")
elif r_idx % 2 == 0:
srgb.set("val", "EEF6F4")
else:
srgb.set("val", "FFFFFF")
for p in cell.text_frame.paragraphs:
p.alignment = PP_ALIGN.CENTER
for run in p.runs:
run.font.name = FONT
run.font.size = Pt(11 if r_idx > 0 else 12)
run.font.bold = r_idx == 0
if header and r_idx == 0:
run.font.color.rgb = WHITE
else:
run.font.color.rgb = DARK
rPr = run._r.get_or_add_rPr()
from lxml import etree as ET
ea = rPr.find(qn("a:ea"))
if ea is None:
ea = ET.SubElement(rPr, qn("a:ea"))
ea.set("typeface", FONT)
if col_widths:
for i, w in enumerate(col_widths):
for cell in table.columns[i].cells if False else []:
pass
table.columns[i].width = w
def fill_table(table, data, header=True):
for r, row_data in enumerate(data):
for c, val in enumerate(row_data):
cell = table.cell(r, c)
cell.text = str(val)
for p in cell.text_frame.paragraphs:
for run in p.runs:
run.font.name = FONT
style_table(table, header=header)
def add_table(slide, left, top, width, height, rows, cols, data, col_widths=None):
table_shape = slide.shapes.add_table(rows, cols, left, top, width, height)
table = table_shape.table
if col_widths:
for i, w in enumerate(col_widths):
table.columns[i].width = w
fill_table(table, data)
return table
def card(slide, left, top, width, height, title, body, accent=TEAL):
shape = add_rect(slide, left, top, width, height, fill=WHITE, line=GRAY_LIGHT)
bar = add_rect_sharp(slide, left, top, Inches(0.12), height, fill=accent)
add_textbox(slide, left + Inches(0.25), top + Inches(0.12), width - Inches(0.35), Inches(0.35), title, size=14, bold=True, color=DARK)
box = slide.shapes.add_textbox(left + Inches(0.25), top + Inches(0.45), width - Inches(0.4), height - Inches(0.55))
tf = box.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
run = p.add_run()
run.text = body
set_run_font(run, size=11, color=GRAY)
return shape
# ==================== 各页 ====================
def slide_cover(prs):
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_rect_sharp(slide, 0, 0, SLIDE_W, SLIDE_H, fill=DARK)
# decorative shapes
add_rect(slide, Inches(9.5), Inches(-0.5), Inches(5), Inches(4), fill=TEAL)
s = slide.shapes[-1]
try:
s.adjustments[0] = 0.2
except Exception:
pass
# make teal semi by using another block
add_rect_sharp(slide, Inches(10.8), Inches(4.2), Inches(3), Inches(4), fill=TEAL_DARK)
add_rect_sharp(slide, 0, Inches(6.6), SLIDE_W, Inches(0.9), fill=TEAL)
add_textbox(slide, Inches(0.7), Inches(1.3), Inches(9), Inches(0.4), "高校实训 · 需求答辩", size=16, color=TEAL)
add_textbox(
slide,
Inches(0.7),
Inches(1.9),
Inches(10),
Inches(1.4),
"智慧医院 AI 影像诊断与\n电子病历辅助决策系统",
size=32,
bold=True,
color=WHITE,
)
add_textbox(
slide,
Inches(0.7),
Inches(3.6),
Inches(9),
Inches(0.5),
"Smart Hospital | 业务闭环 × 可演示 AI × 前后端分离",
size=14,
color=RGBColor(0xB8, 0xD4, 0xE8),
)
add_textbox(
slide,
Inches(0.7),
Inches(4.5),
Inches(9),
Inches(0.8),
"小组组员:成员 A · 成员 B · 成员 C · 成员 D · 成员 E · 成员 F",
size=13,
color=WHITE,
)
add_textbox(slide, Inches(0.7), Inches(5.2), Inches(8), Inches(0.4), "文档版本 v1.0 | 2026-07", size=12, color=RGBColor(0xB8, 0xD4, 0xE8))
add_textbox(
slide,
Inches(0.7),
Inches(6.75),
Inches(12),
Inches(0.4),
"本系统输出仅供教学实训与辅助决策演示,不能替代执业医师正式诊断",
size=11,
color=WHITE,
)
def slide_toc(prs):
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide)
add_header_bar(slide, "汇报目录", "需求答辩六大板块 · 约 15 分钟讲述节奏")
items = [
("01", "背景与目标", "痛点、主题定位、建设目标与应用场景"),
("02", "角色与需求", "三角色诉求、P0/P1/P2 优先级与验收要点"),
("03", "功能需求深挖", "影像 AI、病历决策、配套业务与功能全景"),
("04", "非功能与边界", "性能/安全/可用性、约束假设与已知边界"),
("05", "方案如何落地", "逻辑架构与技术选型如何服务需求"),
("06", "组织与验收", "六人分工、演示剧本、总结与展望"),
]
for i, (num, title, desc) in enumerate(items):
col = i % 3
row = i // 3
left = Inches(0.5 + col * 4.2)
top = Inches(1.4 + row * 2.6)
add_rect(slide, left, top, Inches(3.9), Inches(2.2), fill=WHITE, line=GRAY_LIGHT)
circ = slide.shapes.add_shape(MSO_SHAPE.OVAL, left + Inches(0.25), top + Inches(0.35), Inches(0.7), Inches(0.7))
fill_shape(circ, TEAL if row == 0 else DARK)
clear_and_set_text(circ, num, size=14, bold=True, color=WHITE)
add_textbox(slide, left + Inches(1.1), top + Inches(0.4), Inches(2.5), Inches(0.5), title, size=16, bold=True, color=DARK)
add_textbox(slide, left + Inches(0.3), top + Inches(1.2), Inches(3.3), Inches(0.8), desc, size=12, color=GRAY)
add_footer(slide, 2)
def slide_pain(prs):
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide)
add_header_bar(slide, "01 项目背景与痛点", "传统教学型医院管理系统难以支撑「真实 AI + 完整业务」答辩演示")
add_textbox(
slide,
Inches(0.5),
Inches(1.2),
Inches(12.3),
Inches(0.4),
"问题本质:业务割裂、AI 不可信、架构耦合、权限薄弱、依赖脆弱 —— 导致实训演示断链、需求无法验收。",
size=13,
color=GRAY,
)
data = [
["痛点", "典型表现", "对需求/答辩的影响"],
["业务割裂", "患者、影像、病历、预约缺少统一档案视图", "无法讲清「以患者为中心」闭环"],
["AI 仅假数据", "随机文案/关键字匹配,无真实检测框与模型链路", "AI 能力不可演示、不可复核"],
["技术栈陈旧", "Thymeleaf 服务端渲染,前后端耦合", "扩展难、角色权限与 SPA 体验弱"],
["权限薄弱", "仅 Session 登录判断,缺角色级接口保护", "管理员/医生/影像师诉求无法区分"],
["不可降级", "外部 AI/LLM 失败则整条链路中断", "答辩现场风险高,验收不稳定"],
]
add_table(
slide,
Inches(0.45),
Inches(1.7),
Inches(12.4),
Inches(4.8),
6,
3,
data,
col_widths=[Inches(2.2), Inches(5.5), Inches(4.7)],
)
add_footer(slide, 3)
def slide_goals(prs):
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide)
add_header_bar(slide, "01 主题定位与建设目标", "双核心链路 + 五项建设目标,定义「做什么」与「做到什么程度」")
# two chain cards
add_rect(slide, Inches(0.45), Inches(1.25), Inches(6.1), Inches(1.7), fill=WHITE, line=GRAY_LIGHT)
add_rect_sharp(slide, Inches(0.45), Inches(1.25), Inches(6.1), Inches(0.4), fill=TEAL)
add_textbox(slide, Inches(0.6), Inches(1.3), Inches(5.8), Inches(0.35), "链路一 · 医学影像 AI", size=13, bold=True, color=WHITE)
add_textbox(
slide,
Inches(0.65),
Inches(1.8),
Inches(5.7),
Inches(1.0),
"影像检查 → YOLO 辅助读片 → 结构化诊断报告\n(检测框 · 置信度 · 所见/印象/建议分段)",
size=13,
color=DARK,
)
add_rect(slide, Inches(6.8), Inches(1.25), Inches(6.1), Inches(1.7), fill=WHITE, line=GRAY_LIGHT)
add_rect_sharp(slide, Inches(6.8), Inches(1.25), Inches(6.1), Inches(0.4), fill=DARK)
add_textbox(slide, Inches(6.95), Inches(1.3), Inches(5.8), Inches(0.35), "链路二 · 电子病历辅助决策", size=13, bold=True, color=WHITE)
add_textbox(
slide,
Inches(7.0),
Inches(1.8),
Inches(5.7),
Inches(1.0),
"病历录入 → AI 决策(治疗/用药/护理/随访)\n→ 知识库引用(RAG)与冲突/风险提示",
size=13,
color=DARK,
)
data = [
["建设目标", "需求含义", "可验收表现"],
["业务闭环", "登录、患者、影像、病历、预约、用户管理全覆盖", "各模块可走通主路径"],
["AI 可演示", "YOLO 真实检测 + LLM/模板报告与决策", "有框、有置信度、有分段报告"],
["前后端分离", "Vue SPA + Spring REST + FastAPI AI", "职责清晰,便于分模块讲解"],
["可离线实训", "默认 H2;AI 不可用时可降级", "无 MySQL/无 Key 仍可演示"],
["安全可讲", "JWT + 三角色 + BCrypt + 双重路由守卫", "越权访问 403,密码加密"],
]
add_table(
slide,
Inches(0.45),
Inches(3.2),
Inches(12.4),
Inches(3.5),
6,
3,
data,
col_widths=[Inches(2.2), Inches(5.5), Inches(4.7)],
)
add_footer(slide, 4)
def slide_scenes(prs):
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide)
add_header_bar(slide, "01 应用场景与项目声明", "四类角色场景覆盖日常诊疗演示路径")
scenes = [
("影像科", "RADIOLOGIST", "上传胸片等影像 → 一键 AI 诊断 → 查看 YOLO 标注框、置信度与分段报告", TEAL),
("临床医生", "DOCTOR", "书写病历(高血压/肺炎等关键词)→ 获取治疗、护理、随访建议与知识库来源", DARK),
("系统管理员", "ADMIN", "配置 LLM、管理知识库、切换/上传 YOLO 权重、管理系统用户", ORANGE),
("挂号窗口", "业务协同", "预约登记与状态流转:预约 → 确认 → 完成 / 取消 / 未到诊", TEAL_DARK),
]
for i, (title, role, body, color) in enumerate(scenes):
left = Inches(0.45 + (i % 2) * 6.4)
top = Inches(1.25 + (i // 2) * 2.2)
add_rect(slide, left, top, Inches(6.1), Inches(2.0), fill=WHITE, line=GRAY_LIGHT)
add_rect_sharp(slide, left, top, Inches(0.15), Inches(2.0), fill=color)
add_textbox(slide, left + Inches(0.35), top + Inches(0.25), Inches(3.5), Inches(0.4), title, size=18, bold=True, color=DARK)
badge = add_rect(slide, left + Inches(3.8), top + Inches(0.28), Inches(2.0), Inches(0.35), fill=color)
clear_and_set_text(badge, role, size=10, bold=True, color=WHITE)
add_textbox(slide, left + Inches(0.35), top + Inches(0.85), Inches(5.5), Inches(0.95), body, size=13, color=GRAY)
# declaration banner
add_rect(slide, Inches(0.45), Inches(5.7), Inches(12.4), Inches(1.1), fill=RGBColor(0xFD, 0xF2, 0xE9), line=ORANGE)
add_textbox(
slide,
Inches(0.7),
Inches(5.9),
Inches(12),
Inches(0.8),
"【项目声明】系统输出仅供教学实训与辅助决策演示,不能替代执业医师的正式诊断与医疗文书。\n涉及真实患者数据与临床部署时,需另行满足医疗信息化、隐私与合规要求。",
size=13,
bold=False,
color=RGBColor(0x8E, 0x44, 0x0A),
)
add_footer(slide, 5)
def slide_roles(prs):
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide)
add_header_bar(slide, "02 用户角色与诉求", "角色驱动需求:权限边界清晰,才谈得上功能优先级")
data = [
["角色", "代码枚举", "核心诉求", "关键能力边界"],
["系统管理员", "ADMIN", "用户管理、LLM 配置、YOLO 权重、全局运维", "可见 AI 配置/权重/用户管理菜单"],
["临床医生", "DOCTOR", "患者与病历、辅助决策、预约、查看影像报告", "主业务读写;无系统级配置权"],
["影像医师", "RADIOLOGIST", "影像登记、上传、触发 AI、审阅标注与报告", "聚焦影像链路与诊断结果"],
["访客/未登录", "—", "仅可访问登录页", "业务路由强制跳转登录"],
]
add_table(
slide,
Inches(0.45),
Inches(1.3),
Inches(12.4),
Inches(3.6),
5,
4,
data,
col_widths=[Inches(2.0), Inches(2.2), Inches(4.8), Inches(3.4)],
)
add_textbox(slide, Inches(0.5), Inches(5.15), Inches(12.3), Inches(0.35), "需求推导:前后端双重守卫(路由 meta.roles + 后端 @PreAuthorize)是 F-06 的验收基础。", size=13, bold=True, color=DARK)
# three bullets
points = [
"管理员配置 AI,保证「可演示」不依赖开发者本机临时改配置",
"医生与影像师分权,贴合医院真实岗位,避免「全能账号」掩盖权限需求",
"未登录零业务数据暴露,是安全类非功能需求的最小闭环",
]
for i, t in enumerate(points):
y = Inches(5.55 + i * 0.4)
dot = slide.shapes.add_shape(MSO_SHAPE.OVAL, Inches(0.6), y + Inches(0.08), Inches(0.16), Inches(0.16))
fill_shape(dot, TEAL)
add_textbox(slide, Inches(0.95), y, Inches(11.5), Inches(0.35), t, size=12, color=GRAY)
add_footer(slide, 6)
def slide_priority(prs):
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide)
add_header_bar(slide, "02 功能需求优先级总览", "P0 必须可验收 · P1 重要增强 · P2 体验与健壮性")
# three columns
blocks = [
(
"P0 必须具备",
TEAL,
[
"F-01 登录/登出/改密(JWT+BCrypt)",
"F-02 患者 CRUD + 搜索分页 + 360°",
"F-03 影像 CRUD + 文件上传",
"F-04 AI 影像诊断(状态机+结果)",
"F-05 病历 CRUD + AI 辅助决策",
"F-06 角色权限(前后端双重)",
],
),
(
"P1 重要增强",
DARK,
[
"F-07 预约挂号全流程与统计",
"F-08 仪表盘 KPI 与趋势图",
"F-09 AI 多轮对话助手",
"F-10 知识库管理(RAG 引用)",
"F-11 AI/LLM 配置(管理员)",
"F-12 YOLO 权重管理(管理员)",
],
),
(
"P2 体验健壮",
ORANGE,
[
"F-13 AI 不可用时业务降级",
"F-14 弹窗/抽屉不被布局裁切",
"F-15 报告分段可读展示",
"F-16 路由过渡与看板体验",
"",
"→ 保障答辩现场「不断链」",
],
),
]
for i, (title, color, lines) in enumerate(blocks):
left = Inches(0.4 + i * 4.25)
add_rect(slide, left, Inches(1.25), Inches(4.05), Inches(5.5), fill=WHITE, line=GRAY_LIGHT)
add_rect_sharp(slide, left, Inches(1.25), Inches(4.05), Inches(0.55), fill=color)
add_textbox(slide, left + Inches(0.15), Inches(1.35), Inches(3.7), Inches(0.4), title, size=16, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
for j, line in enumerate(lines):
if not line:
continue
add_textbox(slide, left + Inches(0.2), Inches(2.0 + j * 0.7), Inches(3.65), Inches(0.65), line, size=12, color=DARK)
add_footer(slide, 7)
def slide_panorama(prs):
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide)
add_header_bar(slide, "03 功能全景图", "四大能力域覆盖「身份 → 主数据 → 诊疗 → 智能」")
domains = [
("身份与权限", DARK, ["登录 / JWT", "修改密码", "角色菜单", "头像上传"]),
("业务主数据", TEAL, ["患者管理", "用户管理", "知识库文档", "—"]),
("诊疗业务", TEAL_DARK, ["影像检查", "电子病历", "预约挂号", "仪表盘统计"]),
("智能能力", ORANGE, ["YOLO 影像检测", "AI 诊断报告", "EMR 决策 + RAG", "对话 / LLM / 权重"]),
]
for i, (title, color, items) in enumerate(domains):
left = Inches(0.4 + i * 3.2)
add_rect(slide, left, Inches(1.3), Inches(3.05), Inches(5.3), fill=WHITE, line=GRAY_LIGHT)
add_rect_sharp(slide, left, Inches(1.3), Inches(3.05), Inches(0.7), fill=color)
add_textbox(slide, left, Inches(1.45), Inches(3.05), Inches(0.45), title, size=16, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
for j, it in enumerate(items):
y = Inches(2.3 + j * 1.0)
cell = add_rect(slide, left + Inches(0.2), y, Inches(2.65), Inches(0.75), fill=LIGHT if j % 2 == 0 else GRAY_LIGHT)
clear_and_set_text(cell, it, size=13, bold=False, color=DARK)
add_footer(slide, 8)
def slide_imaging(prs):
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide)
add_header_bar(slide, "03 核心需求① AI 影像诊断(F-03 / F-04)", "异步状态机 + 真实检测结果 + 分段报告 + 可降级")
# flow boxes
steps = [
("新建影像\n上传图片", DARK),
("PENDING", GRAY),
("触发 AI\nANALYZING", TEAL),
("YOLO 检测\n+ 报告生成", TEAL_DARK),
("COMPLETED\n/ ERROR", ORANGE),
("前端轮询\n打开报告", DARK),
]
y = Inches(1.35)
for i, (text, color) in enumerate(steps):
left = Inches(0.35 + i * 2.15)
box = add_rect(slide, left, y, Inches(1.95), Inches(0.95), fill=color)
clear_and_set_text(box, text, size=11, bold=True, color=WHITE)
if i < len(steps) - 1:
add_textbox(slide, left + Inches(1.85), y + Inches(0.25), Inches(0.35), Inches(0.4), "→", size=18, bold=True, color=TEAL)
add_textbox(slide, Inches(0.45), Inches(2.5), Inches(12), Inches(0.35), "状态机:PENDING → ANALYZING → COMPLETED / ERROR(@Async 避免阻塞 HTTP)", size=12, bold=True, color=DARK)
data = [
["验收维度", "要点", "深度说明"],
["状态可见", "列表/KPI 可按状态过滤", "过程可观测,避免「点了没反应」"],
["检测结果", "标注图对比、类别、bbox、置信度", "证明不是假随机文案"],
["报告结构", "所见 / 印象 / 建议 / 声明分块", "满足 F-15 可读性,便于医师复核叙事"],
["降级策略", "AI 宕机时本地规则仍出结果", "满足 F-13,答辩现场不断链"],
["检查类型", "X_RAY / CT / MRI / ULTRASOUND", "覆盖常见影像检查登记场景"],
]
add_table(
slide,
Inches(0.45),
Inches(2.95),
Inches(12.4),
Inches(3.7),
6,
3,
data,
col_widths=[Inches(2.2), Inches(5.0), Inches(5.2)],
)
add_footer(slide, 9)
def slide_emr(prs):
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide)
add_header_bar(slide, "03 核心需求② 电子病历辅助决策(F-05 / F-10)", "结构化病历 → 决策服务 → 多维建议 + RAG 来源可追溯")
flow = [
("填写并保存病历", "主诉/现病史/诊断/用药等"),
("Decision 服务", "Spring 编排调用 AI"),
("RAG + 可选 LLM", "知识检索 / 大模型增强"),
("失败走模板", "关键词规则保底"),
("分节展示结果", "Dialog 多维建议"),
]
for i, (t, s) in enumerate(flow):
left = Inches(0.35 + i * 2.55)
box = add_rect(slide, left, Inches(1.3), Inches(2.4), Inches(1.15), fill=WHITE, line=TEAL)
clear_and_set_text(
box,
[(t, 13, True, DARK), (s, 10, False, GRAY)],
align=PP_ALIGN.CENTER,
)
if i < len(flow) - 1:
add_textbox(slide, left + Inches(2.25), Inches(1.6), Inches(0.35), Inches(0.4), "→", size=16, bold=True, color=TEAL)
# output dimensions
outs = [
("治疗建议", "方案级条目表"),
("用药建议", "药物与注意点"),
("护理建议", "护理要点列表"),
("随访计划", "复诊与监测"),
("风险评估", "潜在风险提示"),
("药物冲突", "相互作用警示"),
("知识来源", "RAG 引用可点查"),
]
add_textbox(slide, Inches(0.45), Inches(2.7), Inches(12), Inches(0.35), "决策输出七维(验收时建议逐项点开):", size=13, bold=True, color=DARK)
for i, (t, s) in enumerate(outs):
left = Inches(0.4 + i * 1.82)
c = add_rect(slide, left, Inches(3.15), Inches(1.72), Inches(1.2), fill=TEAL if i % 2 == 0 else DARK)
clear_and_set_text(c, [(t, 12, True, WHITE), (s, 10, False, GRAY_LIGHT)])
add_rect(slide, Inches(0.45), Inches(4.6), Inches(12.4), Inches(2.1), fill=WHITE, line=GRAY_LIGHT)
add_textbox(
slide,
Inches(0.65),
Inches(4.75),
Inches(12),
Inches(1.8),
"深度要点\n"
"• 诊断含「高血压 / 糖尿病 / 肺炎 / 结节」等关键词时,模板与 RAG 效果更明显,便于课堂对比。\n"
"• 配置 DeepSeek 等 OpenAI 兼容模型后,由 LLM 增强结构化建议;无 Key 时仍可演示。\n"
"• 知识库文档(F-10)与内置医学 Markdown 片段共同支撑「建议有出处」,区别于纯关键字弹窗。\n"
"• 业务价值:缩短教学场景下「从病历到处置思路」的演示路径,并强调人工复核责任。",
size=12,
color=DARK,
)
add_footer(slide, 10)
def slide_support_features(prs):
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide)
add_header_bar(slide, "03 配套业务需求", "支撑双 AI 主链路的业务底座与管理能力")
data = [
["模块", "需求编号", "关键能力", "验收关注点"],
["登录与个人中心", "F-01", "JWT 持久化、改密后重登、头像", "未登录拦截 / 已登录跳转仪表盘"],
["患者管理", "F-02", "分页搜索、CRUD、360° 档案抽屉", "影像/病历/预约关联计数正确"],
["预约挂号", "F-07", "状态机、按日筛选、统计卡片", "预约→确认→完成/取消/未到诊"],
["仪表盘", "F-08", "KPI、近 7 日趋势、类型/状态分布", "ECharts 与快捷入口可导览"],
["AI 助手", "F-09", "多轮对话、Markdown、历史持久化", "历史不随 H2 清空丢失"],
["知识库 / AI 配置 / YOLO", "F-10~12", "文档、LLM 同步、权重激活", "仅 ADMIN;同步后走大模型"],
["用户管理", "F-06 配套", "三角色 CRUD、禁停当前账号", "doctor 访问 /users → 403"],
]
add_table(
slide,
Inches(0.35),
Inches(1.25),
Inches(12.6),
Inches(5.5),
8,
4,
data,
col_widths=[Inches(2.6), Inches(1.6), Inches(4.2), Inches(4.2)],
)
add_footer(slide, 11)
def slide_nfr(prs):
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide)
add_header_bar(slide, "04 非功能需求", "非功能不是「附加项」,而是答辩可演示与可讲解的质量属性")
data = [
["类别", "要求", "项目落地", "对应价值"],
["性能", "诊断不阻塞请求线程", "@Async + 线程池;前端轮询有上限", "长任务可感知、可取消清理"],
["可用性", "无 MySQL 亦可演示", "H2 内存库 + DataInitializer 种子", "降低环境门槛"],
["安全", "鉴权、加密、CORS", "Spring Security + JWT + 白名单", "角色隔离可讲可验"],
["可维护", "统一响应与异常", "Result<T> + GlobalExceptionHandler", "前后端契约稳定"],
["可扩展", "AI 与业务解耦", "FastAPI 独立进程 + base-url 配置", "视觉/LLM 依赖不污染 Java"],
["兼容", "大模型可切换", "OpenAI 兼容协议(DeepSeek/Qwen)", "无厂商锁定"],
]
add_table(
slide,
Inches(0.35),
Inches(1.25),
Inches(12.6),
Inches(5.5),
7,
4,
data,
col_widths=[Inches(1.6), Inches(3.2), Inches(4.4), Inches(3.4)],
)
add_footer(slide, 12)
def slide_constraints(prs):
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide)
add_header_bar(slide, "04 约束、假设与边界", "主动界定范围:避免把「演示系统」误当成生产 HIS/PACS")
data = [
["类型", "说明", "答辩话术建议"],
["定位约束", "面向实训/课程设计/答辩,非生产 HIS 替代", "强调教学闭环与可演示性"],
["影像假设", "以 JPG/PNG 为主,DICOM 全工作流非重点", "后续扩展方向可答 DICOM"],
["模型边界", "YOLO 权重与类别为演示级,需医师复核", "输出=辅助,不是确诊"],
["数据假设", "H2 内存退出丢库;AI 配置/对话存文件可保留", "持久化可切 MySQL profile"],
["产品边界", "无医保、收费、电子签名、CA 等", "范围控制是需求管理能力"],
["外部依赖", "LLM 受网络/额度/延迟影响,有超时上限", "模板降级是风险应对设计"],
]
add_table(
slide,
Inches(0.4),
Inches(1.25),
Inches(12.5),
Inches(4.6),
7,
3,
data,
col_widths=[Inches(2.0), Inches(6.0), Inches(4.5)],
)
add_textbox(
slide,
Inches(0.5),
Inches(6.05),
Inches(12.3),
Inches(0.7),
"设计取舍摘要:默认 H2 降门槛 · AI 独立进程隔离依赖 · JWT 适配分离架构 · LLM 可关保证无 Key 可演 · 报告强制分段避免「墙文本」。",
size=12,
color=GRAY,
)
add_footer(slide, 13)
def slide_arch(prs):
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide)
add_header_bar(slide, "05 逻辑架构(方案如何满足需求)", "浏览器只访问业务后端;AI 能力集中;失败可降级")
# layers
layers = [
(Inches(3.5), Inches(1.25), Inches(6.3), Inches(0.85), "浏览器 Vue 3 SPA · :5173", TEAL, "身份/页面/图表/报告展示"),
(Inches(3.5), Inches(2.5), Inches(6.3), Inches(0.95), "Spring Boot 业务端 · :8080", DARK, "JWT · JPA · 文件 · 异步编排 · 降级"),
]
for left, top, w, h, title, color, sub in layers:
box = add_rect(slide, left, top, w, h, fill=color)
clear_and_set_text(box, [(title, 14, True, WHITE), (sub, 11, False, GRAY_LIGHT)])
# arrow labels
add_textbox(slide, Inches(5.8), Inches(2.1), Inches(2), Inches(0.35), "↓ /api 代理", size=11, color=GRAY, align=PP_ALIGN.CENTER)
# bottom two
left_box = add_rect(slide, Inches(1.2), Inches(3.9), Inches(4.8), Inches(1.3), fill=WHITE, line=DARK)
clear_and_set_text(left_box, [("H2 / MySQL 业务库", 14, True, DARK), ("患者 · 影像 · 病历 · 预约 · 用户", 11, False, GRAY)])
right_box = add_rect(slide, Inches(7.3), Inches(3.9), Inches(4.8), Inches(1.3), fill=WHITE, line=TEAL)
clear_and_set_text(right_box, [("FastAPI AI · :8001", 14, True, TEAL_DARK), ("YOLO · RAG · LLM · 权重/配置", 11, False, GRAY)])
add_textbox(slide, Inches(3.0), Inches(3.5), Inches(2), Inches(0.35), "↓ 持久化", size=11, color=GRAY, align=PP_ALIGN.CENTER)
add_textbox(slide, Inches(9.0), Inches(3.5), Inches(2.5), Inches(0.35), "↓ HTTP(可选/可降级)", size=11, color=GRAY, align=PP_ALIGN.CENTER)
# principles
principles = [
"1. 浏览器不直连 AI 端口(统一鉴权与编排)",
"2. Spring 负责落库、任务状态与降级",
"3. 管理端 LLM 配置单向同步至 FastAPI 运行时",
"4. AI 超时/宕机 → 规则/模板结果保底演示",
]
for i, p in enumerate(principles):
left = Inches(0.5 + (i % 2) * 6.4)
top = Inches(5.5 + (i // 2) * 0.55)
add_textbox(slide, left, top, Inches(6.2), Inches(0.45), p, size=12, color=DARK)
add_footer(slide, 14)
def slide_tech(prs):
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide)
add_header_bar(slide, "05 技术选型对照", "选型服务于需求:可演示、可分离、可降级、可讲解")
data = [
["层级", "技术", "服务的需求", "为何选"],
["前端", "Vue3 + Vite + Element Plus + ECharts", "F-06/08/14/16 体验与权限路由", "SPA 分离、组件化、图表答辩直观"],
["业务后端", "Spring Boot 3.3 + Security + JPA", "F-01~07 业务与安全闭环", "成熟权限模型、异步、统一异常"],
["数据", "H2 默认 / MySQL 可选", "可用性 + 可迁移", "开箱演示;需要持久再切 profile"],
["AI 服务", "FastAPI + Ultralytics YOLO", "F-04 真实检测", "Python 视觉生态,与 Java 解耦"],
["决策/报告", "LangChain 生态 + OpenAI 兼容 LLM", "F-05/09/10/11", "RAG+可关 LLM,无 Key 可演"],
["认证", "JWT + BCrypt", "F-01/06 安全", "无状态,适配前后端分离"],
]
add_table(
slide,
Inches(0.3),
Inches(1.25),
Inches(12.7),
Inches(5.5),
7,
4,
data,
col_widths=[Inches(1.6), Inches(4.0), Inches(3.6), Inches(3.5)],
)
add_footer(slide, 15)
def slide_team(prs):
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide)
add_header_bar(slide, "06 小组分工(6 人)", "主责清晰 · 需求编号可追溯 · 交付物可检查(姓名可替换)")
data = [["成员", "角色定位", "主责模块", "对应需求", "主要交付物"]]
for row in MEMBERS:
data.append(list(row))
add_table(
slide,
Inches(0.25),
Inches(1.2),
Inches(12.8),
Inches(4.6),
7,
5,
data,
col_widths=[Inches(1.3), Inches(2.4), Inches(3.3), Inches(2.2), Inches(3.6)],
)
add_textbox(
slide,
Inches(0.4),
Inches(5.95),
Inches(12.5),
Inches(0.85),
"协作关系:A 统筹需求与验收 → B/C 前端联调 → D/E 业务后端与数据 → F 提供 AI 能力;D/E 负责鉴权编排与降级兜底。\n"
"建议联调节奏:先 F-01/06 打通登录权限 → 患者/影像主数据 → AI 诊断 → 病历决策 → 管理端配置与降级演练。",
size=12,
color=GRAY,
)
add_footer(slide, 16)
def slide_acceptance(prs):
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide)
add_header_bar(slide, "06 验收标准与演示剧本", "把需求变成可执行检查项与 7 步演示路径")
left_title = add_rect(slide, Inches(0.4), Inches(1.2), Inches(6.1), Inches(0.45), fill=TEAL)
clear_and_set_text(left_title, "验证清单(节选)", size=14, bold=True, color=WHITE)
checks = [
"三端启动,5173 可登录",
"admin 可见用户管理;doctor1 访问 /users 为 403",
"患者增删改与 360° 档案抽屉正常",
"影像 AI 可达 COMPLETED,报告分段清晰",
"病历 AI 建议含多类内容与来源",
"预约状态可流转",
"改密后需重新登录",
"关闭 AI 服务后诊断仍可降级返回",
]
for i, c in enumerate(checks):
y = Inches(1.8 + i * 0.55)
box = add_rect(slide, Inches(0.4), y, Inches(0.35), Inches(0.35), fill=TEAL)
clear_and_set_text(box, "✓", size=12, bold=True, color=WHITE)
add_textbox(slide, Inches(0.9), y, Inches(5.5), Inches(0.4), c, size=12, color=DARK)
right_title = add_rect(slide, Inches(6.8), Inches(1.2), Inches(6.1), Inches(0.45), fill=DARK)
clear_and_set_text(right_title, "推荐演示剧本", size=14, bold=True, color=WHITE)
script = [
"1. doctor1 / pass123 登录,看仪表盘 KPI",
"2. 患者管理 → 打开 360° 档案",
"3. 影像:上传样例 → AI 诊断 → 原图/标注对比",
"4. 病历:诊断填「高血压/肺炎」→ 看 AI 建议",
"5. 预约:新建并切换状态",
"6. 切换 admin:AI 配置 / YOLO / 用户",
"7. 关闭 AI 服务,对比降级结果",
]
accounts = [
("admin", "admin123", "ADMIN"),
("doctor1", "pass123", "DOCTOR"),
("radio1", "radio123", "RADIOLOGIST"),
]
for i, s in enumerate(script):
add_textbox(slide, Inches(7.0), Inches(1.8 + i * 0.45), Inches(5.8), Inches(0.4), s, size=12, color=DARK)
add_textbox(slide, Inches(7.0), Inches(5.1), Inches(5.8), Inches(0.3), "内置演示账号", size=12, bold=True, color=DARK)
for i, (u, p, r) in enumerate(accounts):
add_textbox(slide, Inches(7.0), Inches(5.45 + i * 0.35), Inches(5.8), Inches(0.35), f"{u} / {p} · {r}", size=11, color=GRAY)
add_footer(slide, 17)
def slide_summary(prs):
slide = prs.slides.add_slide(prs.slide_layouts[6])
add_bg(slide)
add_header_bar(slide, "总结与展望", "需求闭环已定义清晰;实现路径可演示、可分工、可验收")
# summary cards
sums = [
("需求主线", "双链路:影像 YOLO 诊断 + 病历 RAG/LLM 决策,外加完整业务底座"),
("质量主线", "P0 可验收、权限可讲、AI 可降级、环境可离线实训"),
("组织主线", "6 人按前端/后端/AI/统筹切开,需求编号追溯到交付物"),
]
for i, (t, b) in enumerate(sums):
left = Inches(0.4 + i * 4.25)
add_rect(slide, left, Inches(1.25), Inches(4.05), Inches(1.8), fill=WHITE, line=GRAY_LIGHT)
add_rect_sharp(slide, left, Inches(1.25), Inches(4.05), Inches(0.45), fill=TEAL if i != 2 else DARK)
add_textbox(slide, left + Inches(0.15), Inches(1.32), Inches(3.7), Inches(0.35), t, size=14, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
add_textbox(slide, left + Inches(0.2), Inches(1.9), Inches(3.65), Inches(1.0), b, size=12, color=DARK)
add_textbox(slide, Inches(0.5), Inches(3.3), Inches(12), Inches(0.4), "后续可扩展(超出当前范围,可作为提问储备)", size=14, bold=True, color=DARK)
exts = [
("DICOM 解析\n与序列阅片", TEAL),
("报告 PDF\n与电子签收", DARK),
("细粒度权限\n与操作审计", TEAL_DARK),
("向量库增强\nRAG", ORANGE),
("Docker Compose\n一键拉起", DARK),
("接口自动化\n与 CI", TEAL),
]
for i, (t, c) in enumerate(exts):
left = Inches(0.4 + i * 2.15)
box = add_rect(slide, left, Inches(3.85), Inches(2.05), Inches(1.35), fill=c)
clear_and_set_text(box, t, size=12, bold=True, color=WHITE)
add_rect(slide, Inches(0.4), Inches(5.5), Inches(12.5), Inches(1.2), fill=DARK)
add_textbox(
slide,
Inches(0.7),
Inches(5.7),
Inches(12),
Inches(0.9),
"谢谢各位老师! 欢迎提问\nSmart Hospital · 需求答辩 · 组员 A–F · 输出仅供教学演示",
size=16,
bold=True,
color=WHITE,
align=PP_ALIGN.CENTER,
)
add_footer(slide, 18)
def main():
prs = Presentation()
prs.slide_width = SLIDE_W
prs.slide_height = SLIDE_H
slide_cover(prs)
slide_toc(prs)
slide_pain(prs)
slide_goals(prs)
slide_scenes(prs)
slide_roles(prs)
slide_priority(prs)
slide_panorama(prs)
slide_imaging(prs)
slide_emr(prs)
slide_support_features(prs)
slide_nfr(prs)
slide_constraints(prs)
slide_arch(prs)
slide_tech(prs)
slide_team(prs)
slide_acceptance(prs)
slide_summary(prs)
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
prs.save(str(OUT_PATH))
print(f"OK: {OUT_PATH}")
print(f"slides: {len(prs.slides)}")
if __name__ == "__main__":
main()