802 lines
32 KiB
Python
802 lines
32 KiB
Python
# -*- 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()
|