1053 lines
44 KiB
Python
1053 lines
44 KiB
Python
# -*- 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()
|