Files
Shixun/ppt/build_ppt.py
T
2026-07-31 12:27:41 +08:00

802 lines
32 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 -*-
"""Build Smart Hospital defense PPT (19 slides) — layout-safe revision."""
from __future__ import annotations
from pathlib import Path
from PIL import Image as PILImage
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.oxml import parse_xml
from pptx.oxml.ns import qn
from pptx.util import Inches, Pt
ROOT = Path(__file__).resolve().parent
ASSETS = ROOT / "assets"
OUT = ROOT / "智慧医院_AI影像诊断与电子病历辅助决策系统_答辩PPT.pptx"
OUT_ALT = ROOT / "智慧医院_答辩PPT_优化版.pptx"
SLIDE_W = Inches(13.333)
SLIDE_H = Inches(7.5)
# Safe content band (below header, above footer)
HEADER_H = 0.92
CONTENT_TOP = 1.05
FOOTER_TOP = 7.12
CONTENT_BOTTOM = 7.05
CONTENT_H = CONTENT_BOTTOM - CONTENT_TOP # ~6.0"
NAVY = RGBColor(0x0B, 0x3A, 0x5C)
TEAL = RGBColor(0x1A, 0x7A, 0x9C)
ACCENT = RGBColor(0x2B, 0xBB, 0xAD)
LIGHT = RGBColor(0xF4, 0xF8, 0xFB)
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
DARK = RGBColor(0x1E, 0x29, 0x3B)
MUTED = RGBColor(0x64, 0x74, 0x8B)
SOFT = RGBColor(0xE2, 0xEC, 0xF4)
ORANGE = RGBColor(0xF5, 0x9E, 0x0B)
GREEN = RGBColor(0x22, 0xC5, 0x5E)
RED = RGBColor(0xEF, 0x44, 0x44)
CHIP = RGBColor(0x12, 0x4A, 0x6A)
PANEL = RGBColor(0x0B, 0x2A, 0x45)
FONT = "微软雅黑"
TOTAL = 19
def set_run_font(run, size=14, bold=False, color=DARK, name=FONT):
run.font.size = Pt(size)
run.font.bold = bold
run.font.color.rgb = color
run.font.name = name
rPr = run._r.get_or_add_rPr()
ea = rPr.find(qn("a:ea"))
if ea is None:
ea = parse_xml(
f'<a:ea xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" typeface="{name}"/>'
)
rPr.append(ea)
else:
ea.set("typeface", name)
def add_textbox(
slide,
left,
top,
width,
height,
text,
size=14,
bold=False,
color=DARK,
align=PP_ALIGN.LEFT,
anchor=MSO_ANCHOR.TOP,
):
box = slide.shapes.add_textbox(left, top, width, height)
tf = box.text_frame
tf.word_wrap = True
tf.auto_size = None
try:
tf.paragraphs[0].space_before = Pt(0)
tf.paragraphs[0].space_after = Pt(0)
except Exception:
pass
p = tf.paragraphs[0]
p.alignment = align
run = p.add_run()
run.text = text
set_run_font(run, size=size, bold=bold, color=color)
return box
def add_paras(slide, left, top, width, height, lines, size=12, color=DARK, spacing=4, bold=False):
box = slide.shapes.add_textbox(left, top, width, height)
tf = box.text_frame
tf.word_wrap = True
for i, line in enumerate(lines):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.alignment = PP_ALIGN.LEFT
p.space_before = Pt(0)
p.space_after = Pt(spacing)
run = p.add_run()
run.text = line
set_run_font(run, size=size, bold=bold, color=color)
return box
def add_shape(slide, left, top, width, height, fill=NAVY, line=None):
shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, width, height)
shape.fill.solid()
shape.fill.fore_color.rgb = fill
if line is None:
shape.line.fill.background()
else:
shape.line.color.rgb = line
shape.line.width = Pt(1)
try:
shape.adjustments[0] = 0.08
except Exception:
pass
return shape
def fill_shape_text(shape, lines, size=13, bold=False, color=WHITE, align=PP_ALIGN.CENTER, v_anchor=MSO_ANCHOR.MIDDLE):
"""Put multi-line text inside a shape (avoids separate overlapping textboxes)."""
tf = shape.text_frame
tf.clear()
tf.word_wrap = True
try:
tf.auto_size = None
except Exception:
pass
if isinstance(lines, str):
lines = lines.split("\n")
for i, line in enumerate(lines):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.alignment = align
p.space_before = Pt(0)
p.space_after = Pt(2)
run = p.add_run()
run.text = line
set_run_font(run, size=size, bold=bold if i == 0 else bold, color=color)
return shape
def add_table(slide, left, top, width, height, data, col_widths=None, font_size=11):
rows, cols = len(data), len(data[0])
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
for r in range(rows):
for c in range(cols):
cell = table.cell(r, c)
cell.text = ""
tf = cell.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT
p.space_before = Pt(0)
p.space_after = Pt(0)
run = p.add_run()
run.text = str(data[r][c])
is_header = r == 0
set_run_font(run, size=font_size if not is_header else font_size + 1, bold=is_header, color=WHITE if is_header else DARK)
cell.fill.solid()
cell.fill.fore_color.rgb = NAVY if is_header else (SOFT if r % 2 == 0 else WHITE)
return table_shape
def set_slide_bg(slide, color=LIGHT):
fill = slide.background.fill
fill.solid()
fill.fore_color.rgb = color
def header_bar(slide, title, subtitle=None):
"""Single text frame for title+subtitle to avoid stacked textbox overlap."""
add_shape(slide, Inches(0), Inches(0), SLIDE_W, Inches(HEADER_H), NAVY)
add_shape(slide, Inches(0), Inches(HEADER_H), SLIDE_W, Inches(0.05), ACCENT)
box = slide.shapes.add_textbox(Inches(0.4), Inches(0.12), Inches(12.5), Inches(0.72))
tf = box.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT
p.space_before = Pt(0)
p.space_after = Pt(2)
run = p.add_run()
run.text = title
set_run_font(run, size=22, bold=True, color=WHITE)
if subtitle:
p2 = tf.add_paragraph()
p2.alignment = PP_ALIGN.LEFT
p2.space_before = Pt(0)
p2.space_after = Pt(0)
run2 = p2.add_run()
run2.text = subtitle
set_run_font(run2, size=11, bold=False, color=RGBColor(0xB8, 0xD4, 0xE8))
def footer(slide, page):
add_shape(slide, Inches(0), Inches(FOOTER_TOP), SLIDE_W, Inches(7.5 - FOOTER_TOP), NAVY)
box = slide.shapes.add_textbox(Inches(0.3), Inches(FOOTER_TOP + 0.04), Inches(10.5), Inches(0.28))
tf = box.text_frame
p = tf.paragraphs[0]
run = p.add_run()
run.text = "智慧医院 AI 影像诊断与电子病历辅助决策系统 · 实训答辩 · 仅供教学演示"
set_run_font(run, size=10, color=RGBColor(0xC5, 0xD8, 0xE8))
box2 = slide.shapes.add_textbox(Inches(11.3), Inches(FOOTER_TOP + 0.04), Inches(1.7), Inches(0.28))
p2 = box2.text_frame.paragraphs[0]
p2.alignment = PP_ALIGN.RIGHT
run2 = p2.add_run()
run2.text = f"{page} / {TOTAL}"
set_run_font(run2, size=10, bold=True, color=WHITE)
def fit_image(slide, path, left, top, max_width, max_height):
"""Place image scaled to fit inside max box; never exceed max_height (prevents footer crash)."""
path = Path(path)
if not path.exists():
return None
with PILImage.open(path) as im:
iw, ih = im.size
aspect = iw / ih
# convert max to inches floats
mw = max_width if isinstance(max_width, float) else max_width / 914400
mh = max_height if isinstance(max_height, float) else max_height / 914400
# left/top may be Inches
def to_in(v):
return v if isinstance(v, float) else v / 914400
l = to_in(left)
t = to_in(top)
w = mw
h = w / aspect
if h > mh:
h = mh
w = h * aspect
return slide.shapes.add_picture(str(path), Inches(l), Inches(t), width=Inches(w), height=Inches(h))
def blank_slide(prs):
return prs.slides.add_slide(prs.slide_layouts[6])
def card(slide, left, top, width, height, title, body_lines, color=TEAL, title_size=14, body_size=12):
"""Card with colored title band + body text, all non-overlapping zones."""
add_shape(slide, Inches(left), Inches(top), Inches(width), Inches(height), WHITE, SOFT)
title_h = 0.48
band = add_shape(slide, Inches(left), Inches(top), Inches(width), Inches(title_h), color)
fill_shape_text(band, [title], size=title_size, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
# body region strictly below band
body_top = top + title_h + 0.12
body_h = height - title_h - 0.2
if body_lines:
add_paras(
slide,
Inches(left + 0.15),
Inches(body_top),
Inches(width - 0.3),
Inches(body_h),
body_lines,
size=body_size,
color=DARK,
spacing=5,
)
# ---------------- slides ----------------
def slide_01_cover(prs):
slide = blank_slide(prs)
fit_image(slide, ASSETS / "cover_bg.png", 0, 0, 13.333, 7.5)
# Center panel — leave room for disclaimer
add_shape(slide, Inches(1.1), Inches(1.35), Inches(11.1), Inches(4.35), PANEL)
add_textbox(slide, Inches(1.4), Inches(1.55), Inches(10.5), Inches(0.35),
"SMART HOSPITAL · DEFENSE PRESENTATION", size=13, color=ACCENT, align=PP_ALIGN.CENTER)
add_textbox(slide, Inches(1.4), Inches(2.05), Inches(10.5), Inches(1.1),
"智慧医院 AI 影像诊断与\n电子病历辅助决策系统", size=32, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
add_textbox(slide, Inches(1.4), Inches(3.25), Inches(10.5), Inches(0.4),
"实训教学演示级 Web 应用 · 业务系统 + AI 微服务混合架构",
size=15, color=RGBColor(0xB8, 0xD4, 0xE8), align=PP_ALIGN.CENTER)
chips = [("文档版本", "v1.0"), ("日期", "2026-07-27"), ("前端", "Vue3 :5173"),
("业务端", "Boot :8080"), ("AI", "FastAPI :8001")]
for i, (k, v) in enumerate(chips):
x = 1.5 + i * 2.05
chip = add_shape(slide, Inches(x), Inches(4.0), Inches(1.9), Inches(0.95), CHIP)
fill_shape_text(chip, [k, v], size=12, bold=False, color=WHITE, align=PP_ALIGN.CENTER)
# make first line accent-ish by rewriting as single centered block is OK
add_textbox(slide, Inches(1.2), Inches(6.2), Inches(10.9), Inches(0.7),
"声明:本系统输出仅供教学实训与辅助决策演示,不能替代执业医师的正式诊断与医疗文书。",
size=12, color=RGBColor(0xE2, 0xEC, 0xF4), align=PP_ALIGN.CENTER)
def slide_02_toc(prs):
slide = blank_slide(prs)
set_slide_bg(slide)
header_bar(slide, "目录导览", "课程/实训答辩叙事结构")
sections = [
("01", "主题与需求", "P3 – P6", "主题定位 · 场景角色 · 痛点改造 · 需求优先级", TEAL),
("02", "功能与业务", "P7 – P11", "功能全景 · 核心模块 · 影像诊断 · 病历决策 · 管理端", ACCENT),
("03", "架构与技术", "P12 – P16", "技术栈 · 逻辑架构 · 工程结构 · 核心流程 · 数据与安全", NAVY),
("04", "部署与总结", "P17 – P19", "运行部署 · 演示路径 · 设计取舍 · 边界与展望", ORANGE),
]
# 4 rows fit in 1.15 ~ 6.9
row_h = 1.25
gap = 0.12
start_y = 1.15
for i, (num, title, pages, desc, color) in enumerate(sections):
y = start_y + i * (row_h + gap)
add_shape(slide, Inches(0.55), Inches(y), Inches(12.2), Inches(row_h), WHITE, SOFT)
num_box = add_shape(slide, Inches(0.55), Inches(y), Inches(1.25), Inches(row_h), color)
fill_shape_text(num_box, [num], size=26, bold=True, color=WHITE)
add_textbox(slide, Inches(2.05), Inches(y + 0.22), Inches(7.2), Inches(0.4),
title, size=20, bold=True, color=NAVY)
add_textbox(slide, Inches(2.05), Inches(y + 0.7), Inches(7.5), Inches(0.35),
desc, size=12, color=MUTED)
page_box = add_shape(slide, Inches(10.4), Inches(y + 0.35), Inches(1.9), Inches(0.55), color)
fill_shape_text(page_box, [pages], size=13, bold=True, color=WHITE)
footer(slide, 2)
def slide_03_theme(prs):
slide = blank_slide(prs)
set_slide_bg(slide)
header_bar(slide, "项目主题与建设目标", "围绕医院诊疗两条核心链路展开")
# Left image max height keeps clear of footer
fit_image(slide, ASSETS / "dual_chain.png", 0.3, 1.12, 8.0, 5.7)
# Right goals — fixed band, no overflow
gx, gy, gw, gh = 8.5, 1.12, 4.4, 5.7
add_shape(slide, Inches(gx), Inches(gy), Inches(gw), Inches(gh), WHITE, SOFT)
band = add_shape(slide, Inches(gx), Inches(gy), Inches(gw), Inches(0.45), NAVY)
fill_shape_text(band, ["建设目标五维"], size=14, bold=True, color=WHITE)
goals = [
("业务闭环", "登录、患者、影像、病历、预约、用户管理"),
("AI 可演示", "YOLO 实检 + DeepSeek 兼容 / 模板降级"),
("前后端分离", "Vue3 SPA + Spring Boot + FastAPI"),
("可离线实训", "默认 H2,AI 宕机业务仍可演示"),
("安全可讲", "JWT · 角色权限 · BCrypt · 双端守卫"),
]
item_h = 0.95
for i, (t, d) in enumerate(goals):
y = gy + 0.55 + i * item_h
item = add_shape(slide, Inches(gx + 0.15), Inches(y), Inches(gw - 0.3), Inches(0.85), SOFT)
fill_shape_text(item, [t, d], size=12, bold=False, color=DARK, align=PP_ALIGN.LEFT)
footer(slide, 3)
def slide_04_scenes(prs):
slide = blank_slide(prs)
set_slide_bg(slide)
header_bar(slide, "应用场景与用户角色", "教学/演示四类场景 + 三角色权限模型")
# Image top region
fit_image(slide, ASSETS / "scene_cards.png", 0.4, 1.1, 12.5, 2.7)
data = [
["角色", "代码枚举", "核心诉求"],
["系统管理员", "ADMIN", "用户管理、AI 大模型配置、YOLO 权重、全局运维"],
["临床医生", "DOCTOR", "患者与病历、辅助决策、预约、查看影像报告"],
["影像医师", "RADIOLOGIST", "影像登记、上传、触发 AI 诊断、审阅标注与报告"],
["访客/未登录", "—", "仅可访问登录页"],
]
add_table(slide, Inches(0.4), Inches(4.0), Inches(12.5), Inches(2.85), data,
col_widths=[Inches(2.2), Inches(2.4), Inches(7.9)], font_size=12)
footer(slide, 4)
def slide_05_pain(prs):
slide = blank_slide(prs)
set_slide_bg(slide)
header_bar(slide, "需求分析:痛点与改造", "从教学型 CRUD 到可演示的 AI 混合架构")
data = [
["问题", "传统表现", "本项目对策"],
["业务割裂", "缺统一档案视图", "患者 360° 档案关联全业务"],
["AI 仅假数据", "随机文案/关键字", "YOLO 实检 + LLM/RAG,可降级"],
["技术栈陈旧", "Thymeleaf 耦合", "Vue3 SPA + REST 分离"],
["权限薄弱", "仅 Session 登录", "JWT + 角色 + 双端守卫"],
["不可降级", "依赖失败即中断", "Spring 规则/模板兜底"],
]
add_table(slide, Inches(0.35), Inches(1.15), Inches(7.0), Inches(3.55), data,
col_widths=[Inches(1.5), Inches(2.5), Inches(3.0)], font_size=11)
fit_image(slide, ASSETS / "transform.png", 7.55, 1.15, 5.4, 3.55)
nfr = add_shape(slide, Inches(0.35), Inches(4.9), Inches(12.6), Inches(1.95), WHITE, TEAL)
fill_shape_text(
nfr,
[
"非功能需求落地",
"性能:@Async 异步诊断 + 有限轮询 | 可用性:默认 H2 + 种子数据 | 安全:JWT / BCrypt / CORS",
"可维护:Result<T> + 全局异常 | 可扩展:AI 独立进程 | 兼容:OpenAI 协议(DeepSeek / Qwen)",
],
size=12,
color=DARK,
align=PP_ALIGN.LEFT,
)
footer(slide, 5)
def slide_06_priority(prs):
slide = blank_slide(prs)
set_slide_bg(slide)
header_bar(slide, "功能需求优先级", "P0 必须 · P1 增强 · P2 体验(文档 2.3)")
fit_image(slide, ASSETS / "priority_board.png", 0.35, 1.1, 12.6, 5.8)
footer(slide, 6)
def slide_07_panorama(prs):
slide = blank_slide(prs)
set_slide_bg(slide)
header_bar(slide, "功能全景", "身份权限 · 主数据 · 诊疗业务 · 智能能力")
fit_image(slide, ASSETS / "feature_panorama.png", 0.3, 1.1, 8.7, 5.8)
routes = [
"/login 登录",
"/dashboard 仪表盘",
"/patients 患者",
"/imaging 影像诊断",
"/emrs 电子病历",
"/appointments 预约",
"/ai-assistant 助手",
"/ai-knowledge 知识库",
"/ai-settings ADMIN",
"/ai-yolo ADMIN",
"/users ADMIN",
]
card(slide, 9.2, 1.1, 3.7, 5.8, "前端路由一览", ["• " + r for r in routes], TEAL, body_size=12)
footer(slide, 7)
def slide_08_modules(prs):
slide = blank_slide(prs)
set_slide_bg(slide)
header_bar(slide, "核心业务模块", "仪表盘 · 患者 360° · 预约状态机")
cards = [
("仪表盘 Dashboard", TEAL, [
"• KPI:患者 / 影像 / 病历 / 预约及状态分布",
"• 近 7 日业务趋势(ECharts)",
"• 检查类型、诊断状态分布饼图",
"• 快捷入口与待办,便于演示导览",
]),
("患者管理 + 360° 档案", ACCENT, [
"• 分页列表、姓名等关键字搜索",
"• 证件、电话、地址、既往史",
"• 抽屉关联:影像 / 病历 / 预约",
"• 表单等宽两列,弹窗体验优化",
]),
("预约挂号", ORANGE, [
"• 新建 / 编辑 / 删除预约",
"• 状态:预约 → 确认 → 完成",
"• 亦可:取消 / 未到诊",
"• 按日筛选、统计卡片、详情抽屉",
]),
]
for i, (title, color, items) in enumerate(cards):
card(slide, 0.35 + i * 4.3, 1.15, 4.1, 5.7, title, items, color, title_size=15, body_size=13)
footer(slide, 8)
def slide_09_imaging(prs):
slide = blank_slide(prs)
set_slide_bg(slide)
header_bar(slide, "影像诊断模块(深度)", "检查登记 · 状态机 · YOLO 结果展示 · 可降级")
# Row 1: capability + state machine
card(slide, 0.3, 1.1, 4.0, 2.35, "检查登记能力", [
"• 类型:X_RAY / CT / MRI / ULTRASOUND",
"• 选择患者、部位、上传或路径",
"• 筛选:关键字 / 状态 / 类型",
"• 状态 KPI 卡片可快速过滤",
], TEAL, body_size=11)
fit_image(slide, ASSETS / "state_machine.png", 4.5, 1.1, 8.4, 2.35)
# Row 2: yolo + results — constrained heights
fit_image(slide, ASSETS / "yolo_concept.png", 0.3, 3.6, 5.3, 3.3)
card(slide, 5.8, 3.6, 7.1, 3.3, "诊断结果展示清单", [
"① 诊断印象 + 置信度仪表盘",
"② 原始影像 vs YOLO 标注图对比",
"③ 影像所见(分段)+ 建议编号列表",
"④ 检测明细:类别 / 置信度 / bbox",
"⑤ 完整报告:头 / 所见 / 印象 / 建议 / 声明",
"⑥ AI 不可用 → Spring 本地规则降级",
], ACCENT, body_size=12)
footer(slide, 9)
def slide_10_emr(prs):
slide = blank_slide(prs)
set_slide_bg(slide)
header_bar(slide, "电子病历与 AI 辅助决策(深度)", "结构化录入 → RAG/LLM 决策 → 分节 Dialog")
fit_image(slide, ASSETS / "decision_flow.png", 0.35, 1.1, 12.6, 2.5)
# Bottom two cards
fields = ["主诉", "现病史", "体格检查", "诊断", "治疗方案", "用药", "随访"]
add_shape(slide, Inches(0.35), Inches(3.8), Inches(6.2), Inches(3.05), WHITE, SOFT)
band = add_shape(slide, Inches(0.35), Inches(3.8), Inches(6.2), Inches(0.45), TEAL)
fill_shape_text(band, ["病历字段链路"], size=13, bold=True, color=WHITE)
for i, f in enumerate(fields):
x = 0.55 + (i % 4) * 1.45
y = 4.45 + (i // 4) * 0.95
chip = add_shape(slide, Inches(x), Inches(y), Inches(1.35), Inches(0.7), TEAL if i < 4 else ACCENT)
fill_shape_text(chip, [f], size=12, bold=True, color=WHITE)
card(slide, 6.8, 3.8, 6.15, 3.05, "决策输出七块 + 关键词增强", [
"• 输出:治疗 / 用药 / 护理 / 随访 / 风险 / 冲突 / RAG 来源",
"• 关键词:高血压 · 糖尿病 · 肺炎 · 结节",
"• 配置 LLM 后由大模型增强;失败回退模板",
"• 保存后可自动弹出;列表可再次查看",
], ACCENT, body_size=12)
footer(slide, 10)
def slide_11_admin(prs):
slide = blank_slide(prs)
set_slide_bg(slide)
header_bar(slide, "智能运营与管理端", "AI 助手 · 知识库 · LLM 配置 · YOLO 权重 · 用户权限")
mods = [
("AI 助手", ["多轮对话 · Markdown", "marked + DOMPurify", "history → chat-history.json"], TEAL),
("知识库", ["文档 CRUD · 启停", "配合 RAG 管线", "内置医学 MD 片段"], ACCENT),
("AI 配置", ["仅 ADMIN", "Base URL / Key / Model", "settings.json 持久化"], NAVY),
("YOLO 管理", ["仅 ADMIN", "权重列表/激活/上传", "real / demo 模式"], ORANGE),
]
for i, (t, lines, c) in enumerate(mods):
card(slide, 0.3 + i * 3.25, 1.1, 3.1, 2.4, t, ["• " + x for x in lines], c, body_size=11)
fit_image(slide, ASSETS / "llm_sync.png", 0.3, 3.7, 7.9, 3.15)
card(slide, 8.4, 3.7, 4.5, 3.15, "权限双端防护", [
"• 前端:路由 meta.roles 隐藏/拦截",
"• 后端:@PreAuthorize 接口鉴权",
"• 用户 CRUD:角色/科室/启停",
"• 禁止停用当前登录账号",
"• doctor 访问 /users → 403",
], NAVY, body_size=12)
footer(slide, 11)
def slide_12_tech(prs):
slide = blank_slide(prs)
set_slide_bg(slide)
header_bar(slide, "技术栈总览", "版本号与文档附录 A 对齐")
# Image top, table bottom — no overlap
fit_image(slide, ASSETS / "tech_layers.png", 0.4, 1.1, 12.5, 2.7)
data = [
["层级", "关键技术", "版本/说明"],
["前端", "Vue / Vite / Element Plus / Pinia / ECharts", "3.5.10 / 5.4.8 / 2.8.4 / 2.2.4 / 5.5.1"],
["业务端", "Spring Boot / Java / jjwt / JPA", "3.3.4 / 17 / 0.12.6"],
["数据库", "H2 默认 · MySQL 可选 profile", "mem:smart_hospital / application-mysql.yml"],
["AI 服务", "FastAPI / YOLO / LangChain / LLM", "≥0.110 / Ultralytics / ≥0.2 / DeepSeek 兼容"],
["工程", "前端代理 /api→8080;AI base-url 8001", "uploads/ · data/ai/ · data/weights/"],
]
add_table(slide, Inches(0.4), Inches(4.0), Inches(12.5), Inches(2.9), data,
col_widths=[Inches(1.5), Inches(5.6), Inches(5.4)], font_size=11)
footer(slide, 12)
def slide_13_arch(prs):
slide = blank_slide(prs)
set_slide_bg(slide)
header_bar(slide, "系统逻辑架构", "浏览器 → 业务端 → 数据库 / AI 微服务")
fit_image(slide, ASSETS / "architecture.png", 0.35, 1.1, 12.6, 5.85)
footer(slide, 13)
def slide_14_modules_dir(prs):
slide = blank_slide(prs)
set_slide_bg(slide)
header_bar(slide, "工程目录与模块结构", "frontend · smart-hospital · ai-service")
fit_image(slide, ASSETS / "module_tree.png", 0.25, 1.1, 8.3, 5.8)
card(slide, 8.7, 1.1, 4.2, 5.8, "关键落盘与配置", [
"• ./uploads/images 影像文件",
"• ./uploads 头像等",
"• ./data/ai/settings.json",
"• ./data/ai/chat-history.json",
"• ai-service/data/weights/",
"• ai-service/app/knowledge/",
"• jwt.* 过期与前缀",
"• cors → localhost:5173",
"• ai.service.base-url:8001",
"• multipart 最大约 500MB",
"• H2 控制台 /h2-console",
], NAVY, body_size=12)
footer(slide, 14)
def slide_15_flow(prs):
slide = blank_slide(prs)
set_slide_bg(slide)
header_bar(slide, "核心流程:AI 影像诊断", "异步状态机 + FastAPI 检测报告 + 前端轮询")
fit_image(slide, ASSETS / "imaging_flow.png", 0.3, 1.1, 12.7, 5.85)
footer(slide, 15)
def slide_16_data_api(prs):
slide = blank_slide(prs)
set_slide_bg(slide)
header_bar(slide, "数据模型 · 接口 · 安全", "实体关系、统一响应、REST 与鉴权要点")
fit_image(slide, ASSETS / "er_diagram.png", 0.25, 1.1, 6.3, 4.0)
# Right column stacked, no collision
card(slide, 6.7, 1.1, 6.2, 1.35, "统一响应 Result<T>", [
'{ "code": 0, "message": "OK", "data": {} }',
"code != 0 → Axios 拦截器 ElMessage 提示",
], TEAL, body_size=11)
data = [
["方法/路径", "说明", "权限"],
["POST /api/auth/login", "登录签发 JWT", "公开"],
["GET /api/stats/overview", "仪表盘统计", "登录"],
["POST .../analyze/{id}", "触发诊断", "登录"],
["GET .../ai-suggestions", "辅助决策", "登录"],
["* /api/users/**", "用户管理", "ADMIN"],
["AI /imaging/analyze 等", "检测/决策/RAG", "经业务端"],
]
add_table(slide, Inches(6.7), Inches(2.6), Inches(6.2), Inches(2.5), data,
col_widths=[Inches(2.7), Inches(2.0), Inches(1.5)], font_size=10)
card(slide, 0.25, 5.25, 12.65, 1.6, "安全要点", [
"JWT 无状态 · BCrypt 密码 · CORS 白名单 · 角色 ADMIN / DOCTOR / RADIOLOGIST",
"前端路由守卫 + 后端 @PreAuthorize · 修改密码后需重新登录",
], NAVY, body_size=12)
footer(slide, 16)
def slide_17_deploy(prs):
slide = blank_slide(prs)
set_slide_bg(slide)
header_bar(slide, "部署运行与演示账号", "启动顺序 · 健康检查 · 内置账号 · 演示剧本")
env = [
["组件", "要求"],
["JDK", "17+"],
["Node.js", "18+"],
["Python", "3.10+(推荐 3.11/3.12)"],
["可选", "MySQL 8.x;GPU 非必须"],
]
add_table(slide, Inches(0.3), Inches(1.1), Inches(3.9), Inches(2.15), env,
col_widths=[Inches(1.2), Inches(2.7)], font_size=11)
health = [
["服务", "地址"],
["前端", "http://localhost:5173"],
["业务 API", "http://localhost:8080/api/..."],
["H2 控制台", "http://localhost:8080/h2-console"],
["AI 健康", "http://127.0.0.1:8001/health"],
["AI 文档", "http://127.0.0.1:8001/docs"],
]
add_table(slide, Inches(4.4), Inches(1.1), Inches(4.5), Inches(2.15), health,
col_widths=[Inches(1.4), Inches(3.1)], font_size=10)
card(slide, 9.1, 1.1, 3.85, 2.15, "启动顺序", [
"1) ai-service :8001",
"2) smart-hospital :8080",
"3) frontend :5173",
"推荐:先 AI → 业务 → 前端",
], ACCENT, body_size=11)
accounts = [
["用户名", "密码", "角色", "说明"],
["admin", "admin123", "ADMIN", "用户 / AI 配置 / YOLO"],
["doctor1", "pass123", "DOCTOR", "临床主演示"],
["radio1", "radio123", "RADIOLOGIST", "影像演示"],
]
add_table(slide, Inches(0.3), Inches(3.45), Inches(12.7), Inches(1.55), accounts,
col_widths=[Inches(2.2), Inches(2.5), Inches(2.8), Inches(5.2)], font_size=12)
fit_image(slide, ASSETS / "demo_timeline.png", 0.3, 5.2, 12.7, 1.7)
footer(slide, 17)
def slide_18_design(prs):
slide = blank_slide(prs)
set_slide_bg(slide)
header_bar(slide, "设计取舍 · 边界 · 展望", "答辩可讲清「为什么这样设计」")
cols = [
("关键设计取舍", TEAL, [
"• 默认 H2 → 降低实训门槛",
"• AI 独立进程 → 隔离 Python 依赖",
"• JWT 无状态 → 适配前后端分离",
"• LLM 可关 → 无 Key 仍可演示",
"• 报告强制分段 → 避免墙文本",
"• Dialog append-to-body → 防裁切",
]),
("已知边界(非缺陷)", ORANGE, [
"• 非完整 HIS/PACS/RIS 产品",
"• 无医保 / 收费 / CA / 电子签",
"• YOLO 为演示级权重与类别",
"• H2 重启丢失业务表数据",
"• 外网 LLM 受网络/额度影响",
"• DICOM 完整工作流非重点",
]),
("后续可扩展", ACCENT, [
"• DICOM 解析与序列阅片",
"• 报告 PDF 与医师签收",
"• 更细科室权限与审计",
"• 向量库增强 RAG",
"• Docker Compose 一键拉起",
"• 接口自动化测试与 CI",
]),
]
for i, (title, color, items) in enumerate(cols):
card(slide, 0.3 + i * 4.3, 1.1, 4.15, 4.35, title, items, color, body_size=12)
cmp_data = [
["维度", "早期", "当前"],
["前端", "Thymeleaf + Bootstrap", "Vue3 + Element Plus + ECharts"],
["安全", "HttpSession", "Spring Security + JWT"],
["AI 影像", "规则/随机模拟", "YOLO 实检 + LLM/模板报告"],
]
add_table(slide, Inches(0.3), Inches(5.6), Inches(12.7), Inches(1.3), cmp_data,
col_widths=[Inches(2.0), Inches(5.2), Inches(5.5)], font_size=11)
footer(slide, 18)
def slide_19_end(prs):
slide = blank_slide(prs)
fit_image(slide, ASSETS / "closing_bg.png", 0, 0, 13.333, 7.5)
add_textbox(slide, Inches(1), Inches(1.1), Inches(11.3), Inches(0.55),
"总结与致谢", size=30, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
add_textbox(slide, Inches(1.2), Inches(1.7), Inches(10.9), Inches(0.45),
"智慧医院 = 业务闭环 + 真 AI 链路 + 可降级演示 + 可讲清的工程与安全",
size=14, color=ACCENT, align=PP_ALIGN.CENTER)
values = [
("业务闭环", "患者—影像—病历—预约统一"),
("真 AI 链路", "YOLO 检测 + RAG/LLM 决策"),
("可降级", "无 Key / AI 宕机仍可答辩"),
("工程可讲", "三端分离 · JWT · 异步状态机"),
]
for i, (t, d) in enumerate(values):
x = 1.15 + i * 2.85
box = add_shape(slide, Inches(x), Inches(2.4), Inches(2.65), Inches(1.45), CHIP)
fill_shape_text(box, [t, d], size=12, bold=False, color=WHITE, align=PP_ALIGN.CENTER)
add_textbox(slide, Inches(1.0), Inches(4.15), Inches(11.3), Inches(0.55),
"验证要点:三端可登录 · 角色 403 正确 · 影像 COMPLETED 报告分段 · 病历建议多类 · 预约可流转 · 改密需重登",
size=12, color=RGBColor(0xC5, 0xD8, 0xE8), align=PP_ALIGN.CENTER)
add_textbox(slide, Inches(1), Inches(4.9), Inches(11.3), Inches(0.55),
"谢谢聆听 · 欢迎提问", size=26, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
add_textbox(slide, Inches(1.2), Inches(5.7), Inches(10.9), Inches(0.9),
"声明:输出内容仅供教学实训与辅助决策演示,不能替代执业医师正式诊断与医疗文书。\n"
"文档依据:项目详细文档.md v1.0 · 2026-07-27",
size=11, color=RGBColor(0xB8, 0xD4, 0xE8), align=PP_ALIGN.CENTER)
def main():
prs = Presentation()
prs.slide_width = SLIDE_W
prs.slide_height = SLIDE_H
slide_01_cover(prs)
slide_02_toc(prs)
slide_03_theme(prs)
slide_04_scenes(prs)
slide_05_pain(prs)
slide_06_priority(prs)
slide_07_panorama(prs)
slide_08_modules(prs)
slide_09_imaging(prs)
slide_10_emr(prs)
slide_11_admin(prs)
slide_12_tech(prs)
slide_13_arch(prs)
slide_14_modules_dir(prs)
slide_15_flow(prs)
slide_16_data_api(prs)
slide_17_deploy(prs)
slide_18_design(prs)
slide_19_end(prs)
try:
prs.save(OUT)
print(f"Saved: {OUT}")
except PermissionError:
prs.save(OUT_ALT)
print(f"原文件被占用,已另存: {OUT_ALT}")
print(f"Slides: {len(prs.slides)}")
if __name__ == "__main__":
main()