26-7-31-1

This commit is contained in:
shuai
2026-07-31 12:27:41 +08:00
commit 9b8b8b57b3
142 changed files with 22408 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+801
View File
@@ -0,0 +1,801 @@
# -*- 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()
+656
View File
@@ -0,0 +1,656 @@
# -*- coding: utf-8 -*-
"""
智慧医院项目答辩 PPT v2
结构:项目主题 → 项目需求 → 技术栈 → 实现功能 → 六人分工
要求:图文表并茂、色彩丰富、字体统一、≤20 页、布局防重叠
"""
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_v2"
OUT = ROOT / "智慧医院_项目答辩PPT.pptx"
OUT_ALT = ROOT / "智慧医院_项目答辩PPT_v2.pptx"
SLIDE_W = Inches(13.333)
SLIDE_H = Inches(7.5)
HEADER_H = 0.88
FOOTER_Y = 7.12
TOTAL = 18
# —— 统一字体 ——
FONT = "微软雅黑"
SIZE_H1 = 22
SIZE_H2 = 16
SIZE_BODY = 12
SIZE_SMALL = 11
SIZE_TABLE = 11
# —— 多彩配色 ——
NAVY = RGBColor(0x0F, 0x20, 0x5A)
BLUE = RGBColor(0x25, 0x63, 0xEB)
CYAN = RGBColor(0x06, 0xB6, 0xD4)
TEAL = RGBColor(0x14, 0xB8, 0xA6)
PURPLE = RGBColor(0xA8, 0x55, 0xF7)
VIOLET = RGBColor(0x8B, 0x5C, 0xF6)
ORANGE = RGBColor(0xF9, 0x73, 0x16)
CORAL = RGBColor(0xFB, 0x71, 0x85)
PINK = RGBColor(0xEC, 0x48, 0x99)
GREEN = RGBColor(0x22, 0xC5, 0x5E)
INDIGO = RGBColor(0x63, 0x66, 0xF1)
YELLOW = RGBColor(0xEA, 0xB3, 0x08)
DARK = RGBColor(0x0F, 0x17, 0x2A)
MUTED = RGBColor(0x64, 0x74, 0x8B)
LIGHT = RGBColor(0xF8, 0xFA, 0xFC)
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
SOFT = RGBColor(0xF1, 0xF5, 0xF9)
PANEL = RGBColor(0x0C, 0x1A, 0x40)
SECTION_COLORS = [BLUE, CORAL, PURPLE, TEAL, ORANGE] # 五大篇章色
def set_font(run, size=SIZE_BODY, bold=False, color=DARK):
run.font.size = Pt(size)
run.font.bold = bold
run.font.color.rgb = color
run.font.name = FONT
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="{FONT}"/>'
)
rPr.append(ea)
else:
ea.set("typeface", FONT)
def blank(prs):
return prs.slides.add_slide(prs.slide_layouts[6])
def bg(slide, color=LIGHT):
f = slide.background.fill
f.solid()
f.fore_color.rgb = color
def shape(slide, l, t, w, h, fill, line=None):
s = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(l), Inches(t), Inches(w), Inches(h))
s.fill.solid()
s.fill.fore_color.rgb = fill
if line is None:
s.line.fill.background()
else:
s.line.color.rgb = line
s.line.width = Pt(1.25)
try:
s.adjustments[0] = 0.08
except Exception:
pass
return s
def shape_text(s, lines, size=SIZE_BODY, bold=False, color=WHITE, align=PP_ALIGN.CENTER):
tf = s.text_frame
tf.clear()
tf.word_wrap = True
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_font(run, size=size if i == 0 else max(size - 1, 10), bold=(bold and i == 0), color=color)
def textbox(slide, l, t, w, h, text, size=SIZE_BODY, bold=False, color=DARK, align=PP_ALIGN.LEFT):
box = slide.shapes.add_textbox(Inches(l), Inches(t), Inches(w), Inches(h))
tf = box.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = align
p.space_before = Pt(0)
p.space_after = Pt(0)
# multi-line support
first = True
for line in str(text).split("\n"):
p = tf.paragraphs[0] if first else tf.add_paragraph()
first = False
p.alignment = align
p.space_before = Pt(0)
p.space_after = Pt(2)
run = p.add_run()
run.text = line
set_font(run, size=size, bold=bold, color=color)
return box
def paras(slide, l, t, w, h, lines, size=SIZE_BODY, color=DARK, spacing=4):
box = slide.shapes.add_textbox(Inches(l), Inches(t), Inches(w), Inches(h))
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_font(run, size=size, color=color)
return box
def table(slide, l, t, w, h, data, widths=None, fsize=SIZE_TABLE, header_color=NAVY):
rows, cols = len(data), len(data[0])
ts = slide.shapes.add_table(rows, cols, Inches(l), Inches(t), Inches(w), Inches(h))
tb = ts.table
if widths:
for i, wi in enumerate(widths):
tb.columns[i].width = Inches(wi)
for r in range(rows):
for c in range(cols):
cell = tb.cell(r, c)
cell.text = ""
p = cell.text_frame.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_h = r == 0
set_font(run, size=fsize + (1 if is_h else 0), bold=is_h, color=WHITE if is_h else DARK)
cell.fill.solid()
# colorful header / zebra
if is_h:
cell.fill.fore_color.rgb = header_color
else:
cell.fill.fore_color.rgb = SOFT if r % 2 == 0 else WHITE
return ts
def header(slide, title, subtitle, section_idx=0):
color = SECTION_COLORS[section_idx % len(SECTION_COLORS)]
shape(slide, 0, 0, 13.333, HEADER_H, NAVY)
# colorful accent strip
shape(slide, 0, HEADER_H, 13.333, 0.06, color)
# left color badge
badge = shape(slide, 0.25, 0.18, 0.12, 0.52, color)
box = slide.shapes.add_textbox(Inches(0.55), Inches(0.1), Inches(12.3), Inches(0.7))
tf = box.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
run = p.add_run()
run.text = title
set_font(run, size=SIZE_H1, bold=True, color=WHITE)
if subtitle:
p2 = tf.add_paragraph()
run2 = p2.add_run()
run2.text = subtitle
set_font(run2, size=SIZE_SMALL, color=RGBColor(0xB8, 0xD4, 0xF0))
def footer(slide, page):
shape(slide, 0, FOOTER_Y, 13.333, 7.5 - FOOTER_Y, NAVY)
# rainbow dots
colors = [CORAL, ORANGE, YELLOW, TEAL, BLUE, PURPLE]
for i, c in enumerate(colors):
shape(slide, 0.25 + i * 0.22, FOOTER_Y + 0.1, 0.14, 0.14, c)
textbox(slide, 1.7, FOOTER_Y + 0.05, 9.5, 0.28,
"智慧医院 AI 影像诊断与电子病历辅助决策系统 · 项目答辩 · 仅供教学演示",
size=10, color=RGBColor(0xC5, 0xD8, 0xE8))
textbox(slide, 11.4, FOOTER_Y + 0.05, 1.6, 0.28, f"{page} / {TOTAL}",
size=10, bold=True, color=WHITE, align=PP_ALIGN.RIGHT)
def fit_img(slide, path, l, t, max_w, max_h):
path = Path(path)
if not path.exists():
return None
with PILImage.open(path) as im:
iw, ih = im.size
aspect = iw / float(ih)
w, h = max_w, max_w / aspect
if h > max_h:
h = max_h
w = h * aspect
return slide.shapes.add_picture(str(path), Inches(l), Inches(t), width=Inches(w), height=Inches(h))
def card(slide, l, t, w, h, title, lines, color=BLUE, body_size=SIZE_BODY):
shape(slide, l, t, w, h, WHITE, SOFT)
band = shape(slide, l, t, w, 0.46, color)
shape_text(band, [title], size=14, bold=True, color=WHITE)
if lines:
paras(slide, l + 0.15, t + 0.55, w - 0.3, h - 0.7, lines, size=body_size, color=DARK, spacing=4)
# ===================== SLIDES =====================
def s01_cover(prs):
slide = blank(prs)
fit_img(slide, ASSETS / "cover.png", 0, 0, 13.333, 7.5)
shape(slide, 1.0, 1.4, 11.3, 4.5, PANEL)
textbox(slide, 1.3, 1.6, 10.7, 0.35, "PROJECT DEFENSE · SMART HOSPITAL",
size=13, color=CYAN, align=PP_ALIGN.CENTER)
textbox(slide, 1.3, 2.15, 10.7, 1.15, "智慧医院 AI 影像诊断与\n电子病历辅助决策系统",
size=32, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
textbox(slide, 1.3, 3.45, 10.7, 0.4, "实训教学演示级 Web 应用 · 业务系统 + AI 微服务",
size=15, color=RGBColor(0xB8, 0xD4, 0xF0), align=PP_ALIGN.CENTER)
chips = [
("主题", "智慧医院", CORAL),
("架构", "三端分离", ORANGE),
("AI", "YOLO+RAG", TEAL),
("前端", "Vue3", BLUE),
("后端", "Boot3", PURPLE),
("团队", "6 人协作", PINK),
]
for i, (k, v, c) in enumerate(chips):
x = 1.35 + i * 1.8
box = shape(slide, x, 4.15, 1.65, 0.95, c)
shape_text(box, [k, v], size=12, bold=True, color=WHITE)
textbox(slide, 1.2, 5.5, 10.9, 0.35, "文档 v1.0 · 2026-07-27 · 严格依据《项目详细文档》",
size=12, color=RGBColor(0xC5, 0xD8, 0xE8), align=PP_ALIGN.CENTER)
textbox(slide, 1.2, 6.3, 10.9, 0.55,
"声明:输出仅供教学实训与辅助决策演示,不能替代执业医师正式诊断与医疗文书。",
size=11, color=RGBColor(0xE2, 0xEC, 0xF4), align=PP_ALIGN.CENTER)
def s02_toc(prs):
slide = blank(prs)
bg(slide)
header(slide, "目录 · 答辩叙事五篇章", "主题 → 需求 → 技术栈 → 功能 → 分工", 0)
sections = [
("01", "项目主题", "P3 – P4", "定位 · 双链路 · 目标 · 场景", BLUE, "主题"),
("02", "项目需求", "P5 – P7", "痛点 · 角色 · 优先级 · 非功能", CORAL, "需求"),
("03", "技术栈", "P8 – P10", "三层技术 · 架构 · 工程结构", PURPLE, "技术"),
("04", "实现功能", "P11 – P14", "全景 · 影像 · 决策 · 管理安全", TEAL, "功能"),
("05", "六人分工", "P15 – P16", "协作总览 · 职责 · 交付物", ORANGE, "分工"),
("06", "演示总结", "P17 – P18", "演示路径 · 验证 · 致谢", PINK, "收束"),
]
for i, (num, title, pages, desc, color, tag) in enumerate(sections):
col = i % 3
row = i // 3
x = 0.45 + col * 4.25
y = 1.2 + row * 2.7
shape(slide, x, y, 4.05, 2.45, WHITE, color)
band = shape(slide, x, y, 4.05, 0.7, color)
shape_text(band, [f"{num} {title}"], size=18, bold=True, color=WHITE)
textbox(slide, x + 0.2, y + 0.9, 3.6, 0.35, pages, size=14, bold=True, color=color)
textbox(slide, x + 0.2, y + 1.35, 3.6, 0.8, desc, size=13, color=DARK)
footer(slide, 2)
def s03_theme(prs):
slide = blank(prs)
bg(slide)
header(slide, "一、项目主题 · 定位与双链路", "智慧医院 = 影像 AI 读片 + 病历 AI 决策", 0)
fit_img(slide, ASSETS / "dual_chain.png", 0.3, 1.1, 12.7, 5.8)
footer(slide, 3)
def s04_goals_scenes(prs):
slide = blank(prs)
bg(slide)
header(slide, "一、项目主题 · 建设目标与应用场景", "五维目标 + 四类演示场景", 0)
fit_img(slide, ASSETS / "goals.png", 0.3, 1.1, 12.7, 2.5)
fit_img(slide, ASSETS / "scenes.png", 0.3, 3.7, 12.7, 3.15)
footer(slide, 4)
def s05_req_pain(prs):
slide = blank(prs)
bg(slide)
header(slide, "二、项目需求 · 背景痛点与改造", "解决传统教学 HIS 的五类不足", 1)
fit_img(slide, ASSETS / "pain.png", 0.25, 1.1, 8.0, 4.0)
data = [
["问题", "传统表现", "本项目对策"],
["业务割裂", "缺统一视图", "患者 360° 档案"],
["AI 假数据", "随机/关键字", "YOLO + LLM/RAG"],
["技术陈旧", "Thymeleaf 耦合", "Vue3 前后端分离"],
["权限薄弱", "仅 Session", "JWT + 角色双端"],
["不可降级", "依赖失败中断", "规则/模板兜底"],
]
table(slide, 8.4, 1.1, 4.55, 4.0, data, [1.2, 1.5, 1.85], fsize=10, header_color=CORAL)
nfr_note = shape(slide, 0.3, 5.3, 12.7, 1.55, WHITE, TEAL)
shape_text(
nfr_note,
[
"改造结论(文档 2.1)",
"在原始 Spring Boot + Thymeleaf 原型上完成改造 → 当前「前后端分离 + AI 微服务」版本",
"约束:实训/答辩级,非生产 HIS/PACS;影像以 JPG/PNG 为主;YOLO 为演示级需医师复核",
],
size=12,
color=DARK,
align=PP_ALIGN.LEFT,
)
footer(slide, 5)
def s06_req_roles(prs):
slide = blank(prs)
bg(slide)
header(slide, "二、项目需求 · 角色诉求与功能优先级", "三角色 + P0/P1/P2 需求看板", 1)
roles = [
["角色", "枚举", "核心诉求"],
["系统管理员", "ADMIN", "用户 · AI 配置 · YOLO 权重 · 运维"],
["临床医生", "DOCTOR", "患者病历 · 决策 · 预约 · 看报告"],
["影像医师", "RADIOLOGIST", "影像登记上传 · AI 诊断 · 审阅"],
["访客", "—", "仅登录页"],
]
table(slide, 0.3, 1.1, 12.7, 2.0, roles, [2.2, 2.3, 8.2], fsize=12, header_color=BLUE)
fit_img(slide, ASSETS / "priority.png", 0.3, 3.25, 12.7, 3.65)
footer(slide, 6)
def s07_req_nfr(prs):
slide = blank(prs)
bg(slide)
header(slide, "二、项目需求 · 非功能需求", "性能 · 可用性 · 安全 · 可维护 · 可扩展 · 兼容", 1)
fit_img(slide, ASSETS / "nfr.png", 0.3, 1.15, 12.7, 2.4)
data = [
["类别", "要求", "项目落地"],
["性能", "诊断不阻塞 HTTP", "@Async 线程池 + 前端有限轮询"],
["可用性", "无 MySQL 可演示", "默认 H2 + DataInitializer 种子"],
["安全", "鉴权/加密/CORS", "Security + JWT + BCrypt"],
["可维护", "统一响应异常", "Result<T> + GlobalExceptionHandler"],
["可扩展", "AI 与业务解耦", "FastAPI 独立 · ai.service.base-url"],
["兼容", "大模型可切换", "OpenAI 兼容(DeepSeek/Qwen)"],
]
table(slide, 0.3, 3.7, 12.7, 3.15, data, [1.6, 3.5, 7.6], fsize=11, header_color=PURPLE)
footer(slide, 7)
def s08_tech_stack(prs):
slide = blank(prs)
bg(slide)
header(slide, "三、技术栈 · 总体一览", "版本号严格对齐文档附录 A", 2)
fit_img(slide, ASSETS / "tech.png", 0.3, 1.1, 12.7, 3.5)
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", "3.3.4 / 17 / 0.12.6"],
["数据库", "H2 默认 · MySQL 可选", "mem:smart_hospital / profile=mysql"],
["AI", "FastAPI / YOLO / LangChain / LLM", "≥0.110 / Ultralytics / ≥0.2 / DeepSeek"],
]
table(slide, 0.3, 4.75, 12.7, 2.1, data, [1.5, 6.0, 5.2], fsize=11, header_color=PURPLE)
footer(slide, 8)
def s09_tech_arch(prs):
slide = blank(prs)
bg(slide)
header(slide, "三、技术栈 · 系统逻辑架构", "Vue :5173 → Spring :8080 → H2/MySQL & FastAPI :8001", 2)
fit_img(slide, ASSETS / "architecture.png", 0.25, 1.05, 12.8, 5.9)
footer(slide, 9)
def s10_tech_modules(prs):
slide = blank(prs)
bg(slide)
header(slide, "三、技术栈 · 工程结构与数据落盘", "frontend · smart-hospital · ai-service", 2)
fit_img(slide, ASSETS / "modules.png", 0.25, 1.1, 8.5, 5.7)
card(slide, 8.95, 1.1, 4.0, 5.7, "关键配置与落盘", [
"• 前端代理 /api → :8080",
"• jwt.* 密钥/过期/前缀",
"• cors → localhost:5173",
"• ai.service.base-url:8001",
"• ./uploads/images 影像",
"• ./data/ai/settings.json",
"• chat-history.json 对话",
"• ai-service/data/weights/",
"• app/knowledge/ MD 知识",
"• multipart 最大约 500MB",
"• H2 控制台 /h2-console",
"• 启动序:AI→业务→前端",
], VIOLET, body_size=12)
footer(slide, 10)
def s11_feat_panorama(prs):
slide = blank(prs)
bg(slide)
header(slide, "四、实现功能 · 功能全景", "身份权限 · 主数据 · 诊疗业务 · 智能能力", 3)
fit_img(slide, ASSETS / "features.png", 0.2, 1.05, 9.0, 5.85)
routes = [
"/login 登录",
"/dashboard 仪表盘",
"/patients 患者",
"/imaging 影像",
"/emrs 电子病历",
"/appointments 预约",
"/ai-assistant 助手",
"/ai-knowledge 知识库",
"/ai-settings ADMIN",
"/ai-yolo ADMIN",
"/users ADMIN",
]
card(slide, 9.4, 1.1, 3.55, 5.8, "前端路由一览", ["• " + r for r in routes], TEAL, body_size=11)
footer(slide, 11)
def s12_feat_imaging(prs):
slide = blank(prs)
bg(slide)
header(slide, "四、实现功能 · 影像 AI 诊断(深度)", "状态机 · YOLO · 分段报告 · 可降级", 3)
card(slide, 0.25, 1.1, 4.0, 2.2, "检查登记能力", [
"• X_RAY / CT / MRI / ULTRASOUND",
"• 患者、部位、上传/路径",
"• 关键字/状态/类型筛选",
"• 状态 KPI 卡片快速过滤",
], BLUE, body_size=11)
fit_img(slide, ASSETS / "state.png", 4.4, 1.1, 8.55, 2.2)
fit_img(slide, ASSETS / "yolo.png", 0.25, 3.5, 5.4, 3.35)
card(slide, 5.85, 3.5, 7.1, 3.35, "结果展示清单", [
"① 诊断印象 + 置信度仪表盘",
"② 原图 vs YOLO 标注图对比",
"③ 影像所见分段 + 建议列表",
"④ 检测明细:类别/置信度/bbox",
"⑤ 完整报告:头/所见/印象/建议/声明",
"⑥ AI 不可用 → Spring 本地规则降级",
], CORAL, body_size=12)
footer(slide, 12)
def s13_feat_emr_biz(prs):
slide = blank(prs)
bg(slide)
header(slide, "四、实现功能 · 病历决策与业务模块", "辅助决策七块输出 + 仪表盘/患者/预约", 3)
fit_img(slide, ASSETS / "decision.png", 0.25, 1.1, 12.8, 2.7)
cards = [
("仪表盘", BLUE, ["KPI 多维统计", "近7日趋势 ECharts", "类型/状态分布饼图", "快捷入口与待办"]),
("患者 360°", TEAL, ["CRUD + 搜索分页", "证件/联系/既往史", "关联影像/病历/预约", "抽屉档案计数"]),
("预约挂号", ORANGE, ["新建编辑删除", "预约→确认→完成", "取消/未到诊", "按日筛选统计"]),
("关键词增强", PURPLE, ["高血压/糖尿病", "肺炎/结节", "模板+RAG 更明显", "LLM 可进一步增强"]),
]
for i, (t, c, lines) in enumerate(cards):
card(slide, 0.25 + i * 3.25, 4.0, 3.1, 2.85, t, ["• " + x for x in lines], c, body_size=11)
footer(slide, 13)
def s14_feat_admin(prs):
slide = blank(prs)
bg(slide)
header(slide, "四、实现功能 · 管理端、安全与接口", "AI 配置 · 权限双端 · 统一响应 · 实体关系", 3)
mods = [
("AI 助手", ["Markdown 渲染", "DOMPurify 消毒", "历史文件持久化"], CYAN),
("知识库", ["文档 CRUD 启停", "RAG 管线配合", "内置医学片段"], PURPLE),
("AI 配置", ["仅 ADMIN", "URL/Key/Model", "同步 FastAPI"], ORANGE),
("YOLO 管理", ["权重上传激活", "real/demo 模式", "推理统计"], CORAL),
]
for i, (t, lines, c) in enumerate(mods):
card(slide, 0.25 + i * 3.25, 1.1, 3.1, 2.15, t, ["• " + x for x in lines], c, body_size=11)
fit_img(slide, ASSETS / "er.png", 0.25, 3.4, 6.5, 3.45)
data = [
["接口/能力", "权限"],
["POST /api/auth/login", "公开"],
["患者/影像/病历/预约", "已登录"],
["AI 诊断 analyze/result", "已登录"],
["病历 ai-suggestions", "已登录"],
["用户管理 /users", "ADMIN"],
["AI 配置 / YOLO", "ADMIN"],
]
table(slide, 6.95, 3.4, 6.0, 3.45, data, [4.0, 2.0], fsize=11, header_color=INDIGO)
footer(slide, 14)
def s15_team_overview(prs):
slide = blank(prs)
bg(slide)
header(slide, "五、六人分工 · 协作总览", "按三端工程与业务模块划分(姓名可替换)", 4)
fit_img(slide, ASSETS / "team.png", 0.25, 1.05, 12.8, 5.9)
footer(slide, 15)
def s16_team_detail(prs):
"""Balanced layout: compact table on top + full-width swimlane below (no tiny corner image)."""
slide = blank(prs)
bg(slide)
header(slide, "五、六人分工 · 职责与交付物明细", "文档无具名分工 → 按模块可落地的 6 人方案", 4)
data = [
["成员", "角色定位", "主要职责", "关键交付物"],
["成员A", "前端基础/权限", "布局、登录、路由守卫、Pinia、个人中心", "BasicLayout · Login · router"],
["成员B", "前端业务", "仪表盘、患者360°、预约挂号、图表", "Dashboard · Patients · Appointments"],
["成员C", "前端智能交互", "影像报告弹窗、病历决策Dialog、AI助手/知识库页", "Imaging · EMR · AI views"],
["成员D", "后端主数据/安全", "JWT安全、用户、患者、预约、统一响应", "Security · User/Patient API"],
["成员E", "后端AI桥接", "异步诊断、AiClient、决策对接、降级逻辑", "AIDiagnosis · Decision 服务"],
["成员F", "AI微服务", "YOLO检测、报告/决策、RAG、权重与LLM配置", "ai-service 全模块"],
]
# 上半:表格约占 2.9",行高更匀称
table(slide, 0.3, 1.08, 12.7, 2.95, data, [1.3, 2.2, 5.0, 4.2], fsize=12, header_color=ORANGE)
# 下半:全宽泳道图(宽扁比例,贴满宽度,与上表约 1:1 视觉权重)
fit_img(slide, ASSETS / "team_lane.png", 0.3, 4.15, 12.7, 2.8)
footer(slide, 16)
def s17_demo(prs):
slide = blank(prs)
bg(slide)
header(slide, "演示路径 · 账号 · 验证清单", "答辩现场可按 7 步剧本走通", 5)
accounts = [
["用户名", "密码", "角色", "用途"],
["admin", "admin123", "ADMIN", "用户 / AI 配置 / YOLO"],
["doctor1", "pass123", "DOCTOR", "临床主演示"],
["radio1", "radio123", "RADIOLOGIST", "影像演示"],
]
table(slide, 0.3, 1.1, 12.7, 1.7, accounts, [2.2, 2.5, 2.8, 5.2], fsize=12, header_color=TEAL)
fit_img(slide, ASSETS / "demo.png", 0.3, 3.0, 12.7, 1.85)
checks = [
["验证项", "期望"],
["三端启动 5173 可登录", "通过"],
["admin 可见用户管理;doctor 访问 /users → 403", "通过"],
["影像 AI 至 COMPLETED,报告分段清晰", "通过"],
["病历 AI 建议含多类内容 + RAG 来源", "通过"],
["预约状态可流转;改密后需重登", "通过"],
["关闭 AI 后诊断仍返回降级结果", "通过"],
]
table(slide, 0.3, 5.0, 12.7, 1.9, checks, [8.5, 4.2], fsize=11, header_color=BLUE)
footer(slide, 17)
def s18_end(prs):
slide = blank(prs)
fit_img(slide, ASSETS / "closing.png", 0, 0, 13.333, 7.5)
textbox(slide, 1, 1.0, 11.3, 0.55, "总结与致谢", size=30, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
textbox(slide, 1.2, 1.65, 10.9, 0.45,
"主题清晰 · 需求可验 · 技术可讲 · 功能可演示 · 分工可落地",
size=14, color=CYAN, align=PP_ALIGN.CENTER)
points = [
("主题", "双链路智慧医院", CORAL),
("需求", "P0–P2 + 非功能", ORANGE),
("技术", "Vue3+Boot+FastAPI", PURPLE),
("功能", "YOLO·RAG·全业务", TEAL),
("协作", "6 人三端分工", BLUE),
("边界", "教学演示非临床", PINK),
]
for i, (t, d, c) in enumerate(points):
x = 0.7 + (i % 6) * 2.1
box = shape(slide, x, 2.4, 1.95, 1.5, c)
shape_text(box, [t, d], size=12, bold=True, color=WHITE)
textbox(slide, 1, 4.2, 11.3, 0.5, "谢谢聆听 · 欢迎提问", size=26, bold=True, color=WHITE, align=PP_ALIGN.CENTER)
textbox(slide, 1.2, 5.0, 10.9, 0.9,
"声明:仅供教学实训与辅助决策演示,不能替代执业医师正式诊断。\n"
"依据:《项目详细文档.md》v1.0 · 分工表中成员名可按实际名单替换",
size=12, color=RGBColor(0xC5, 0xD8, 0xE8), align=PP_ALIGN.CENTER)
# note about team
textbox(slide, 1.2, 6.2, 10.9, 0.5,
"六人姓名未在文档中给出,PPT 使用「成员A–F」占位,答辩前请替换为真实姓名。",
size=11, color=YELLOW, align=PP_ALIGN.CENTER)
def main():
if not ASSETS.exists():
raise SystemExit("请先运行 gen_assets_v2.py 生成 assets_v2/")
prs = Presentation()
prs.slide_width = SLIDE_W
prs.slide_height = SLIDE_H
s01_cover(prs)
s02_toc(prs)
s03_theme(prs)
s04_goals_scenes(prs)
s05_req_pain(prs)
s06_req_roles(prs)
s07_req_nfr(prs)
s08_tech_stack(prs)
s09_tech_arch(prs)
s10_tech_modules(prs)
s11_feat_panorama(prs)
s12_feat_imaging(prs)
s13_feat_emr_biz(prs)
s14_feat_admin(prs)
s15_team_overview(prs)
s16_team_detail(prs)
s17_demo(prs)
s18_end(prs)
try:
prs.save(OUT)
print("Saved:", OUT)
except PermissionError:
prs.save(OUT_ALT)
print("原文件占用,另存:", OUT_ALT)
print("Slides:", len(prs.slides))
if __name__ == "__main__":
main()
+81
View File
@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
from pathlib import Path
from pptx import Presentation
from pptx.enum.shapes import MSO_SHAPE_TYPE
p = Path(r"E:\桌面\实训项目\smart-hospital\ppt\智慧医院_AI影像诊断与电子病历辅助决策系统_答辩PPT.pptx")
prs = Presentation(str(p))
def box(sh):
return (sh.left, sh.top, sh.left + sh.width, sh.top + sh.height)
def area(b):
return max(0, b[2] - b[0]) * max(0, b[3] - b[1])
def inter(a, b):
l = max(a[0], b[0])
t = max(a[1], b[1])
r = min(a[2], b[2])
btm = min(a[3], b[3])
if r <= l or btm <= t:
return 0
return (r - l) * (btm - t)
def label(sh):
if sh.shape_type == MSO_SHAPE_TYPE.PICTURE:
return "[IMG]"
if sh.has_table:
return "[TABLE]"
t = ""
if hasattr(sh, "text") and sh.text:
t = sh.text.replace("\n", " ").strip()[:50]
return t or f"shape#{sh.shape_id}"
for si, s in enumerate(prs.slides, 1):
shapes = list(s.shapes)
boxes = [(box(sh), label(sh), sh) for sh in shapes]
issues = []
for i in range(len(boxes)):
for j in range(i + 1, len(boxes)):
bi, bj = boxes[i][0], boxes[j][0]
ia = inter(bi, bj)
if ia <= 0:
continue
min_a = min(area(bi), area(bj))
if min_a == 0:
continue
ratio = ia / min_a
# intentional: text on colored bar, footer text on footer bar
# report if ratio high and not pure containment of tiny text in bar
if ratio < 0.25:
continue
t1, t2 = boxes[i][1], boxes[j][1]
# skip text fully inside same-color header/footer style if one is short page num
issues.append((ratio, t1, t2, bi, bj))
# also content past footer
footer_y = int(7.15 * 914400)
overflow = []
for b, lab, sh in boxes:
if b[1] < footer_y < b[3] and b[3] - footer_y > 50000 and "仅供教学" not in lab and " / " not in lab:
# shape crosses into footer zone
if sh.shape_type == MSO_SHAPE_TYPE.PICTURE or sh.has_table or (hasattr(sh, "text") and len(sh.text) > 20):
overflow.append(lab)
if issues or overflow:
print(f"=== Slide {si} overlaps={len(issues)} overflow={len(overflow)} ===")
for ratio, t1, t2, bi, bj in sorted(issues, reverse=True)[:10]:
print(f" r={ratio:.2f}")
print(f" A: {t1}")
print(f" B: {t2}")
print(
f" A inches: L={bi[0]/914400:.2f} T={bi[1]/914400:.2f} R={bi[2]/914400:.2f} B={bi[3]/914400:.2f}"
)
print(
f" B inches: L={bj[0]/914400:.2f} T={bj[1]/914400:.2f} R={bj[2]/914400:.2f} B={bj[3]/914400:.2f}"
)
for o in overflow:
print(f" FOOTER CROSS: {o}")
+722
View File
@@ -0,0 +1,722 @@
# -*- coding: utf-8 -*-
"""Generate diagram assets for Smart Hospital PPT (medical tech blue)."""
from __future__ import annotations
import math
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageFilter
OUT = Path(__file__).resolve().parent / "assets"
OUT.mkdir(parents=True, exist_ok=True)
# Palette
NAVY = (11, 58, 92)
TEAL = (26, 122, 156)
ACCENT = (43, 187, 173)
LIGHT = (244, 248, 251)
WHITE = (255, 255, 255)
DARK = (30, 41, 59)
MUTED = (100, 116, 139)
SOFT = (226, 236, 244)
ORANGE = (245, 158, 11)
GREEN = (34, 197, 94)
RED = (239, 68, 68)
PURPLE = (99, 102, 241)
def font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
candidates = [
r"C:\Windows\Fonts\msyhbd.ttc" if bold else r"C:\Windows\Fonts\msyh.ttc",
r"C:\Windows\Fonts\simhei.ttf",
r"C:\Windows\Fonts\simsun.ttc",
r"C:\Windows\Fonts\arial.ttf",
]
for p in candidates:
try:
return ImageFont.truetype(p, size=size)
except OSError:
continue
return ImageFont.load_default()
def round_rect(draw, xy, r, fill, outline=None, width=1):
draw.rounded_rectangle(xy, radius=r, fill=fill, outline=outline, width=width)
def text_center(draw, xy, text, fnt, fill=WHITE):
bbox = draw.textbbox((0, 0), text, font=fnt)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
x = xy[0] - tw // 2
y = xy[1] - th // 2
draw.text((x, y), text, font=fnt, fill=fill)
def text_left(draw, xy, text, fnt, fill=DARK):
draw.text(xy, text, font=fnt, fill=fill)
def gradient_bg(w, h, c1=NAVY, c2=TEAL):
img = Image.new("RGB", (w, h), c1)
px = img.load()
for y in range(h):
t = y / max(h - 1, 1)
r = int(c1[0] * (1 - t) + c2[0] * t)
g = int(c1[1] * (1 - t) + c2[1] * t)
b = int(c1[2] * (1 - t) + c2[2] * t)
for x in range(w):
# subtle radial-ish variation
edge = abs(x - w / 2) / (w / 2)
k = 0.12 * edge
px[x, y] = (
max(0, min(255, int(r * (1 - k)))),
max(0, min(255, int(g * (1 - k)))),
max(0, min(255, int(b * (1 - k)))),
)
return img
def draw_grid_dots(draw, w, h, step=40, color=(255, 255, 255, 40)):
for x in range(0, w, step):
for y in range(0, h, step):
draw.ellipse((x, y, x + 2, y + 2), fill=color[:3])
def cover_bg():
w, h = 1920, 1080
img = gradient_bg(w, h, NAVY, (8, 90, 110))
overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(overlay)
# abstract circles / network
for i, (cx, cy, r) in enumerate(
[(300, 200, 180), (1600, 250, 220), (1400, 850, 260), (200, 900, 150), (960, 540, 320)]
):
alpha = 28 + (i % 3) * 10
d.ellipse((cx - r, cy - r, cx + r, cy + r), outline=(*ACCENT, alpha), width=3)
# connecting lines
pts = [(320, 260), (700, 400), (1100, 320), (1500, 500), (1200, 700), (600, 720)]
for i in range(len(pts) - 1):
d.line([pts[i], pts[i + 1]], fill=(*ACCENT, 70), width=2)
for p in pts:
d.ellipse((p[0] - 8, p[1] - 8, p[0] + 8, p[1] + 8), fill=(*ACCENT, 160))
# soft panel
d.rounded_rectangle((120, 180, 1800, 900), radius=28, fill=(11, 40, 70, 120), outline=(*ACCENT, 90), width=2)
# medical cross abstract
cx, cy = 1600, 780
d.rounded_rectangle((cx - 18, cy - 55, cx + 18, cy + 55), radius=6, fill=(*ACCENT, 180))
d.rounded_rectangle((cx - 55, cy - 18, cx + 55, cy + 18), radius=6, fill=(*ACCENT, 180))
img = Image.alpha_composite(img.convert("RGBA"), overlay).convert("RGB")
img.save(OUT / "cover_bg.png", quality=95)
print("cover_bg.png")
def dual_chain():
w, h = 1400, 720
img = Image.new("RGB", (w, h), LIGHT)
d = ImageDraw.Draw(img)
f_title = font(28, True)
f_node = font(20, True)
f_sub = font(16)
# title
text_left(d, (40, 24), "双核心业务链路", f_title, NAVY)
def chain(y, title, color, nodes):
round_rect(d, (40, y, w - 40, y + 280), 18, WHITE, SOFT, 2)
d.rectangle((40, y, 52, y + 280), fill=color)
text_left(d, (70, y + 16), title, f_title, color)
n = len(nodes)
box_w, box_h = 220, 100
gap = (w - 120 - n * box_w) // max(n - 1, 1)
xs = []
for i, (t1, t2) in enumerate(nodes):
x = 70 + i * (box_w + gap)
xs.append(x + box_w // 2)
round_rect(d, (x, y + 90, x + box_w, y + 90 + box_h), 14, color, None)
text_center(d, (x + box_w // 2, y + 90 + 38), t1, f_node, WHITE)
text_center(d, (x + box_w // 2, y + 90 + 68), t2, f_sub, (230, 250, 248))
if i < n - 1:
x1 = x + box_w + 8
x2 = x + box_w + gap - 8
midy = y + 90 + box_h // 2
d.line([(x1, midy), (x2, midy)], fill=color, width=3)
d.polygon([(x2, midy), (x2 - 12, midy - 7), (x2 - 12, midy + 7)], fill=color)
chain(
70,
"链路 A · 医学影像 AI 读片",
TEAL,
[("影像检查", "上传 X光/CT/MRI/超声"), ("YOLO 检测", "标注框 + 置信度"), ("结构化报告", "所见/印象/建议")],
)
chain(
390,
"链路 B · 电子病历辅助决策",
ACCENT,
[("病历录入", "主诉→诊断→用药"), ("RAG + LLM", "知识检索与生成"), ("决策建议", "治疗/护理/随访")],
)
img.save(OUT / "dual_chain.png", quality=95)
print("dual_chain.png")
def feature_panorama():
w, h = 1500, 820
img = Image.new("RGB", (w, h), LIGHT)
d = ImageDraw.Draw(img)
f_h = font(30, True)
f_c = font(22, True)
f_i = font(18)
text_left(d, (40, 24), "智慧医院功能全景", f_h, NAVY)
cols = [
("身份与权限", NAVY, ["登录 / JWT", "修改密码", "角色菜单", "头像上传"]),
("业务主数据", TEAL, ["患者管理", "用户管理", "知识库文档"]),
("诊疗业务", ACCENT, ["影像检查", "电子病历", "预约挂号", "仪表盘统计"]),
("智能能力", PURPLE, ["YOLO 影像检测", "AI 诊断报告", "EMR 决策 + RAG", "AI 对话助手", "LLM / YOLO 管理"]),
]
cw = 340
gap = 20
x0 = 40
for i, (title, color, items) in enumerate(cols):
x = x0 + i * (cw + gap)
round_rect(d, (x, 90, x + cw, h - 40), 16, WHITE, SOFT, 2)
round_rect(d, (x, 90, x + cw, 160), 16, color)
# fix bottom of header
d.rectangle((x, 140, x + cw, 160), fill=color)
text_center(d, (x + cw // 2, 125), title, f_c, WHITE)
yy = 190
for it in items:
round_rect(d, (x + 20, yy, x + cw - 20, yy + 48), 10, SOFT)
d.ellipse((x + 36, yy + 16, x + 52, yy + 32), fill=color)
text_left(d, (x + 66, yy + 12), it, f_i, DARK)
yy += 58
img.save(OUT / "feature_panorama.png", quality=95)
print("feature_panorama.png")
def architecture():
w, h = 1600, 900
img = Image.new("RGB", (w, h), LIGHT)
d = ImageDraw.Draw(img)
f_h = font(28, True)
f_b = font(20, True)
f_s = font(16)
text_left(d, (40, 20), "系统逻辑架构", f_h, NAVY)
def box(x, y, bw, bh, title, lines, color):
round_rect(d, (x, y, x + bw, y + bh), 14, WHITE, color, 3)
round_rect(d, (x, y, x + bw, y + 44), 14, color)
d.rectangle((x, y + 28, x + bw, y + 44), fill=color)
text_center(d, (x + bw // 2, y + 22), title, f_b, WHITE)
yy = y + 58
for ln in lines:
text_center(d, (x + bw // 2, yy), ln, f_s, DARK)
yy += 26
# top browser
box(560, 70, 480, 120, "浏览器 Vue SPA", ["localhost:5173", "Element Plus · Pinia · ECharts"], TEAL)
# arrow down
d.line([(800, 190), (800, 240)], fill=NAVY, width=3)
d.polygon([(800, 250), (790, 235), (810, 235)], fill=NAVY)
text_left(d, (820, 205), "/api Vite 代理", f_s, MUTED)
box(480, 260, 640, 140, "Spring Boot 业务端", ["localhost:8080 · JWT / JPA / 文件存储", "鉴权 · 落库 · 任务状态 · 降级"], NAVY)
# split arrows
d.line([(640, 400), (640, 470)], fill=TEAL, width=3)
d.line([(1000, 400), (1000, 470)], fill=ACCENT, width=3)
d.line([(640, 470), (1000, 470)], fill=MUTED, width=2)
d.polygon([(640, 485), (630, 470), (650, 470)], fill=TEAL)
d.polygon([(1000, 485), (990, 470), (1010, 470)], fill=ACCENT)
box(280, 500, 420, 160, "H2 / MySQL 业务库", ["默认 H2 内存 · 可选 MySQL", "DataInitializer 种子数据"], TEAL)
box(900, 500, 480, 180, "FastAPI AI 服务", ["localhost:8001", "YOLO · RAG · LLM 报告/决策", "失败可降级到模板规则"], ACCENT)
# bottom leaves
box(820, 720, 200, 100, "本地权重", ["best.pt 等"], PURPLE)
box(1040, 720, 200, 100, "知识 Markdown", ["高血压/肺炎…"], ORANGE)
box(1260, 720, 220, 100, "外部 LLM API", ["DeepSeek 兼容"], GREEN)
d.line([(1140, 680), (920, 720)], fill=MUTED, width=2)
d.line([(1140, 680), (1140, 720)], fill=MUTED, width=2)
d.line([(1140, 680), (1370, 720)], fill=MUTED, width=2)
# principles strip
round_rect(d, (40, 720, 760, 860), 12, WHITE, SOFT, 2)
text_left(d, (60, 735), "调用原则", f_b, NAVY)
principles = [
"1. 浏览器只访问业务后端(经代理)",
"2. AI 能力集中在 FastAPI",
"3. 配置单向同步:管理端 → AI 运行时",
"4. 失败可降级:超时/宕机仍可演示",
]
yy = 770
for p in principles:
text_left(d, (60, yy), p, f_s, DARK)
yy += 22
img.save(OUT / "architecture.png", quality=95)
print("architecture.png")
def imaging_flow():
w, h = 1600, 900
img = Image.new("RGB", (w, h), LIGHT)
d = ImageDraw.Draw(img)
f_h = font(28, True)
f_n = font(18, True)
f_s = font(15)
text_left(d, (40, 20), "核心流程 · AI 影像诊断", f_h, NAVY)
steps = [
(80, 100, "新建影像记录\n上传图片", TEAL),
(80, 250, "状态 PENDING", MUTED),
(80, 400, "点击「AI 诊断」", ORANGE),
(80, 550, "状态 ANALYZING", ORANGE),
(420, 550, "Spring\nAIDiagnosisService\n@Async", NAVY),
(760, 550, "FastAPI\n/imaging/analyze", ACCENT),
(1100, 450, "YOLO 检测\n绘制标注图", PURPLE),
(1100, 620, "报告生成\nLLM 或模板", TEAL),
(1400, 520, "写结果\n更新记录", GREEN),
(1400, 250, "COMPLETED\n/ ERROR", GREEN),
(1100, 120, "前端轮询\n报告弹窗", TEAL),
]
# draw boxes
positions = {}
for i, (x, y, text, color) in enumerate(steps):
bw, bh = 220, 90
if "\n" in text and text.count("\n") >= 2:
bh = 110
round_rect(d, (x, y, x + bw, y + bh), 12, color)
lines = text.split("\n")
for j, ln in enumerate(lines):
text_center(d, (x + bw // 2, y + 22 + j * 24), ln, f_n if j == 0 else f_s, WHITE)
positions[i] = (x + bw // 2, y + bh // 2, x, y, bw, bh)
def arrow(a, b):
x1, y1 = positions[a][0], positions[a][1]
x2, y2 = positions[b][0], positions[b][1]
# adjust to box edges roughly
d.line([(x1, y1), (x2, y2)], fill=NAVY, width=2)
ang = math.atan2(y2 - y1, x2 - x1)
ax, ay = x2 - 18 * math.cos(ang), y2 - 18 * math.sin(ang)
d.polygon(
[
(ax, ay),
(ax - 10 * math.cos(ang - 0.4), ay - 10 * math.sin(ang - 0.4)),
(ax - 10 * math.cos(ang + 0.4), ay - 10 * math.sin(ang + 0.4)),
],
fill=NAVY,
)
for a, b in [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6), (5, 7), (6, 8), (7, 8), (8, 9), (9, 10)]:
arrow(a, b)
# note
round_rect(d, (420, 100, 1000, 220), 12, WHITE, SOFT, 2)
text_left(d, (440, 115), "技术要点", f_n, NAVY)
notes = [
"· 异步诊断:AsyncConfig 线程池,避免阻塞 HTTP",
"· 前端轮询有上限,组件卸载时清理定时器",
"· AI 不可用时 Spring 本地规则降级,演示不断链",
"· 报告分段:所见 / 印象 / 建议 / 声明",
]
yy = 150
for n in notes:
text_left(d, (440, yy), n, f_s, DARK)
yy += 22
img.save(OUT / "imaging_flow.png", quality=95)
print("imaging_flow.png")
def state_machine():
w, h = 1100, 320
img = Image.new("RGB", (w, h), LIGHT)
d = ImageDraw.Draw(img)
f_n = font(20, True)
f_s = font(14)
states = [
("PENDING", "已登记,待诊断", MUTED),
("ANALYZING", "诊断进行中", ORANGE),
("COMPLETED", "成功,可看报告", GREEN),
("ERROR", "失败", RED),
]
for i, (name, desc, color) in enumerate(states):
x = 40 + i * 270
round_rect(d, (x, 80, x + 200, 200), 16, color)
text_center(d, (x + 100, 130), name, f_n, WHITE)
text_center(d, (x + 100, 170), desc, f_s, WHITE)
if i < 3:
d.line([(x + 210, 140), (x + 255, 140)], fill=NAVY, width=3)
d.polygon([(x + 260, 140), (x + 248, 132), (x + 248, 148)], fill=NAVY)
# branch note from analyzing
text_left(d, (40, 30), "影像诊断状态机", font(24, True), NAVY)
text_left(d, (580, 250), "ANALYZING 后分支到 COMPLETED 或 ERROR", f_s, MUTED)
img.save(OUT / "state_machine.png", quality=95)
print("state_machine.png")
def er_diagram():
w, h = 1200, 700
img = Image.new("RGB", (w, h), LIGHT)
d = ImageDraw.Draw(img)
f_h = font(26, True)
f_n = font(18, True)
f_s = font(14)
text_left(d, (30, 20), "主要实体关系", f_h, NAVY)
def ent(x, y, title, fields, color):
bw = 240
bh = 40 + 22 * len(fields) + 16
round_rect(d, (x, y, x + bw, y + bh), 12, WHITE, color, 2)
round_rect(d, (x, y, x + bw, y + 40), 12, color)
d.rectangle((x, y + 26, x + bw, y + 40), fill=color)
text_center(d, (x + bw // 2, y + 20), title, f_n, WHITE)
yy = y + 52
for f in fields:
text_left(d, (x + 16, yy), f, f_s, DARK)
yy += 22
return x + bw // 2, y + bh // 2, x, y, bw, bh
u = ent(480, 60, "User", ["ADMIN/DOCTOR/RADIOLOGIST", "BCrypt 密码 · 科室"], NAVY)
p = ent(40, 280, "Patient", ["姓名/性别/年龄", "证件/联系/既往史"], TEAL)
im = ent(360, 280, "ImagingRecord", ["类型/部位/图像", "状态机 PENDING…"], ACCENT)
ai = ent(360, 520, "AIDiagnosisResult", ["置信度/所见/建议", "检测JSON/标注图"], PURPLE)
em = ent(700, 280, "ElectronicMedicalRecord", ["主诉→随访全字段", "关联患者/医生"], TEAL)
ap = ent(960, 280, "Appointment", ["预约日/科室", "状态流转"], ORANGE)
def link(a, b, label=""):
d.line([(a[0], a[1]), (b[0], b[1])], fill=MUTED, width=2)
if label:
mx, my = (a[0] + b[0]) // 2, (a[1] + b[1]) // 2
text_left(d, (mx + 4, my - 10), label, f_s, MUTED)
link(u, im)
link(u, em)
link(u, ap)
link(p, im)
link(p, em)
link(p, ap)
link(im, ai, "1:1")
img.save(OUT / "er_diagram.png", quality=95)
print("er_diagram.png")
def llm_sync():
w, h = 1200, 280
img = Image.new("RGB", (w, h), LIGHT)
d = ImageDraw.Draw(img)
f_n = font(18, True)
f_s = font(14)
text_left(d, (30, 16), "LLM 配置同步链路", font(22, True), NAVY)
nodes = [
("管理员\nAI 配置保存", NAVY),
("./data/ai/\nsettings.json", TEAL),
("AI 服务\n/llm-config", ACCENT),
("FastAPI 运行时\n启用大模型", GREEN),
]
for i, (t, c) in enumerate(nodes):
x = 40 + i * 290
round_rect(d, (x, 70, x + 230, 180), 14, c)
lines = t.split("\n")
for j, ln in enumerate(lines):
text_center(d, (x + 115, 110 + j * 28), ln, f_n, WHITE)
if i < 3:
d.line([(x + 240, 125), (x + 275, 125)], fill=NAVY, width=3)
d.polygon([(x + 280, 125), (x + 268, 117), (x + 268, 133)], fill=NAVY)
text_left(d, (40, 220), "报告与决策从模板切换到大模型增强", f_s, MUTED)
img.save(OUT / "llm_sync.png", quality=95)
print("llm_sync.png")
def decision_flow():
w, h = 1300, 420
img = Image.new("RGB", (w, h), LIGHT)
d = ImageDraw.Draw(img)
f_n = font(17, True)
f_s = font(14)
text_left(d, (30, 16), "电子病历辅助决策流程", font(22, True), NAVY)
nodes = [
("保存病历", TEAL),
("DecisionSupport\nService", NAVY),
("FastAPI\n/report/decision", ACCENT),
("RAG + LLM\n或模板回退", PURPLE),
("七类建议\nDialog 展示", GREEN),
]
for i, (t, c) in enumerate(nodes):
x = 30 + i * 255
round_rect(d, (x, 90, x + 220, 200), 14, c)
for j, ln in enumerate(t.split("\n")):
text_center(d, (x + 110, 140 + j * 28), ln, f_n, WHITE)
if i < 4:
d.line([(x + 230, 155), (x + 245, 155)], fill=NAVY, width=3)
d.polygon([(x + 250, 155), (x + 238, 147), (x + 238, 163)], fill=NAVY)
outs = ["治疗", "用药", "护理", "随访", "风险", "冲突", "RAG来源"]
for i, o in enumerate(outs):
x = 40 + i * 175
round_rect(d, (x, 320, x + 155, 380), 10, SOFT, ACCENT, 2)
text_center(d, (x + 77, 350), o, f_s, NAVY)
img.save(OUT / "decision_flow.png", quality=95)
print("decision_flow.png")
def transform_arrow():
w, h = 900, 360
img = Image.new("RGB", (w, h), LIGHT)
d = ImageDraw.Draw(img)
f_n = font(18, True)
f_s = font(14)
text_left(d, (30, 16), "架构演进:从单体到三端分离", font(22, True), NAVY)
round_rect(d, (40, 80, 360, 300), 16, WHITE, MUTED, 2)
round_rect(d, (40, 80, 360, 130), 16, MUTED)
d.rectangle((40, 115, 360, 130), fill=MUTED)
text_center(d, (200, 105), "早期原型", f_n, WHITE)
for i, t in enumerate(["Thymeleaf 服务端渲染", "HttpSession 登录", "规则/随机 AI 模拟", "强依赖 MySQL"]):
text_left(d, (60, 150 + i * 32), "· " + t, f_s, DARK)
# arrow
d.polygon([(400, 180), (480, 160), (480, 175), (560, 175), (560, 185), (480, 185), (480, 200)], fill=ACCENT)
round_rect(d, (580, 80, 860, 300), 16, WHITE, ACCENT, 2)
round_rect(d, (580, 80, 860, 130), 16, ACCENT)
d.rectangle((580, 115, 860, 130), fill=ACCENT)
text_center(d, (720, 105), "当前实现", f_n, WHITE)
for i, t in enumerate(["Vue3 + Element Plus", "Spring Security + JWT", "YOLO 实检 + RAG/LLM", "默认 H2 可离线演示"]):
text_left(d, (600, 150 + i * 32), "· " + t, f_s, DARK)
img.save(OUT / "transform.png", quality=95)
print("transform.png")
def tech_layers():
w, h = 1400, 520
img = Image.new("RGB", (w, h), LIGHT)
d = ImageDraw.Draw(img)
f_h = font(24, True)
f_n = font(18, True)
f_s = font(15)
text_left(d, (30, 16), "三层技术栈", f_h, NAVY)
layers = [
("前端 SPA", TEAL, "Vue 3.5.10 · Vite 5.4.8 · Element Plus 2.8.4 · Pinia · Router · Axios · ECharts · marked"),
("业务后端", NAVY, "Spring Boot 3.3.4 · Java 17 · Security + jjwt 0.12.6 · JPA · H2/MySQL · Lombok · Maven"),
("AI 微服务", ACCENT, "FastAPI ≥0.110 · Ultralytics YOLO · OpenCV · LangChain ≥0.2 · DeepSeek 等 OpenAI 兼容"),
]
for i, (title, color, desc) in enumerate(layers):
y = 70 + i * 140
round_rect(d, (40, y, w - 40, y + 120), 16, WHITE, color, 3)
round_rect(d, (40, y, 220, y + 120), 16, color)
d.rectangle((180, y, 220, y + 120), fill=color)
text_center(d, (130, y + 60), title, f_n, WHITE)
# wrap desc roughly
text_left(d, (250, y + 45), desc, f_s, DARK)
img.save(OUT / "tech_layers.png", quality=95)
print("tech_layers.png")
def scene_cards():
"""Four scene cards as one image for roles page."""
w, h = 1400, 360
img = Image.new("RGB", (w, h), LIGHT)
d = ImageDraw.Draw(img)
f_n = font(20, True)
f_s = font(15)
cards = [
("影像科", "上传胸片 → 一键 AI 诊断\n查看标注框、置信度与分段报告", TEAL),
("临床医生", "书写病历关键词 → 获取\n治疗/护理/随访建议与知识来源", ACCENT),
("管理员", "配置 LLM、管理知识库\n切换/上传 YOLO 权重与用户", NAVY),
("挂号窗口", "预约登记与状态流转\n预约→确认→完成/取消/未到诊", ORANGE),
]
for i, (t, desc, c) in enumerate(cards):
x = 20 + i * 345
round_rect(d, (x, 30, x + 320, 320), 16, WHITE, c, 3)
round_rect(d, (x, 30, x + 320, 90), 16, c)
d.rectangle((x, 70, x + 320, 90), fill=c)
text_center(d, (x + 160, 60), t, f_n, WHITE)
# icon circle
d.ellipse((x + 130, 110, x + 190, 170), fill=SOFT, outline=c, width=3)
text_center(d, (x + 160, 140), str(i + 1), f_n, c)
for j, ln in enumerate(desc.split("\n")):
text_center(d, (x + 160, 200 + j * 28), ln, f_s, DARK)
img.save(OUT / "scene_cards.png", quality=95)
print("scene_cards.png")
def yolo_concept():
"""Simple chest-xray style + detection boxes concept."""
w, h = 900, 560
img = Image.new("RGB", (w, h), (20, 28, 40))
d = ImageDraw.Draw(img)
f_n = font(18, True)
f_s = font(14)
# left original
round_rect(d, (40, 60, 420, 500), 12, (35, 45, 60))
text_center(d, (230, 40), "原始影像(示意)", f_n, ACCENT)
# fake lung-ish shapes
d.ellipse((90, 140, 220, 380), outline=(180, 200, 210), width=2)
d.ellipse((240, 140, 370, 380), outline=(180, 200, 210), width=2)
d.line([(230, 120), (230, 420)], fill=(120, 140, 150), width=2)
d.rectangle((150, 200, 190, 250), outline=(100, 120, 130), width=1)
# right annotated
round_rect(d, (480, 60, 860, 500), 12, (35, 45, 60))
text_center(d, (670, 40), "YOLO 标注结果(示意)", f_n, ACCENT)
d.ellipse((530, 140, 660, 380), outline=(180, 200, 210), width=2)
d.ellipse((680, 140, 810, 380), outline=(180, 200, 210), width=2)
d.line([(670, 120), (670, 420)], fill=(120, 140, 150), width=2)
# detection boxes
d.rectangle((560, 200, 640, 270), outline=ACCENT, width=3)
text_left(d, (560, 175), "findings 0.87", f_s, ACCENT)
d.rectangle((720, 250, 790, 320), outline=ORANGE, width=3)
text_left(d, (700, 225), "nodule 0.76", f_s, ORANGE)
text_left(d, (40, 520), "仅教学演示概念图,非真实患者数据与临床结论", f_s, MUTED)
img.save(OUT / "yolo_concept.png", quality=95)
print("yolo_concept.png")
def module_tree():
w, h = 1200, 700
img = Image.new("RGB", (w, h), LIGHT)
d = ImageDraw.Draw(img)
f_h = font(24, True)
f_n = font(16, True)
f_s = font(14)
text_left(d, (30, 20), "仓库三工程结构", f_h, NAVY)
roots = [
(40, "frontend/", TEAL, ["src/api HTTP 封装", "src/views 业务页面", "src/router 守卫", "src/stores Pinia"]),
(420, "smart-hospital/", NAVY, ["controller/service", "security JWT", "uploads/ 影像头像", "data/ai/ 配置与聊天"]),
(800, "ai-service/", ACCENT, ["api/ imaging·rag", "services/ yolo·llm", "knowledge/ MD", "data/weights/ pt"]),
]
for x, title, color, items in roots:
round_rect(d, (x, 80, x + 340, 640), 16, WHITE, color, 3)
round_rect(d, (x, 80, x + 340, 150), 16, color)
d.rectangle((x, 130, x + 340, 150), fill=color)
text_center(d, (x + 170, 115), title, f_n, WHITE)
yy = 180
for it in items:
round_rect(d, (x + 24, yy, x + 316, yy + 70), 10, SOFT)
text_left(d, (x + 44, yy + 22), it, f_s, DARK)
yy += 90
img.save(OUT / "module_tree.png", quality=95)
print("module_tree.png")
def closing_bg():
w, h = 1920, 1080
img = gradient_bg(w, h, NAVY, (12, 80, 100))
overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(overlay)
d.ellipse((760, 280, 1160, 680), outline=(*ACCENT, 100), width=4)
d.ellipse((820, 340, 1100, 620), outline=(*TEAL, 80), width=3)
# cross
cx, cy = 960, 480
d.rounded_rectangle((cx - 16, cy - 50, cx + 16, cy + 50), radius=6, fill=(*ACCENT, 200))
d.rounded_rectangle((cx - 50, cy - 16, cx + 50, cy + 16), radius=6, fill=(*ACCENT, 200))
img = Image.alpha_composite(img.convert("RGBA"), overlay).convert("RGB")
img.save(OUT / "closing_bg.png", quality=95)
print("closing_bg.png")
def demo_timeline():
w, h = 1500, 280
img = Image.new("RGB", (w, h), LIGHT)
d = ImageDraw.Draw(img)
f_n = font(14, True)
f_s = font(12)
text_left(d, (20, 12), "推荐演示剧本(7 步)", font(20, True), NAVY)
steps = [
"doctor1\n登录仪表盘",
"患者\n360°档案",
"影像\nAI诊断",
"病历\n辅助决策",
"预约\n状态流转",
"admin\nAI/YOLO",
"关闭AI\n验证降级",
]
n = len(steps)
for i, t in enumerate(steps):
x = 40 + i * 210
d.ellipse((x + 60, 60, x + 110, 110), fill=TEAL if i < 5 else NAVY)
text_center(d, (x + 85, 85), str(i + 1), font(18, True), WHITE)
for j, ln in enumerate(t.split("\n")):
text_center(d, (x + 85, 130 + j * 22), ln, f_s, DARK)
if i < n - 1:
d.line([(x + 120, 85), (x + 200, 85)], fill=ACCENT, width=3)
img.save(OUT / "demo_timeline.png", quality=95)
print("demo_timeline.png")
def priority_board():
w, h = 1500, 780
img = Image.new("RGB", (w, h), LIGHT)
d = ImageDraw.Draw(img)
f_h = font(26, True)
f_n = font(18, True)
f_s = font(14)
text_left(d, (30, 16), "功能需求优先级看板", f_h, NAVY)
cols = [
(
"P0 必须具备",
RED,
[
"F-01 登录/登出/改密 · JWT+BCrypt",
"F-02 患者 CRUD + 360°档案",
"F-03 影像 CRUD + 上传",
"F-04 AI 影像诊断状态机",
"F-05 病历 + 辅助决策",
"F-06 角色权限双端控制",
],
),
(
"P1 重要增强",
ORANGE,
[
"F-07 预约挂号全流程",
"F-08 仪表盘可视化",
"F-09 AI 对话助手",
"F-10 知识库管理 RAG",
"F-11 AI/LLM 配置",
"F-12 YOLO 权重管理",
],
),
(
"P2 体验与健壮",
GREEN,
[
"F-13 AI 不可用业务降级",
"F-14 弹窗 append-to-body",
"F-15 报告分段可读",
"F-16 路由过渡与仪表盘体验",
],
),
]
for i, (title, color, items) in enumerate(cols):
x = 30 + i * 490
round_rect(d, (x, 70, x + 460, h - 40), 16, WHITE, color, 3)
round_rect(d, (x, 70, x + 460, 130), 16, color)
d.rectangle((x, 110, x + 460, 130), fill=color)
text_center(d, (x + 230, 100), title, f_n, WHITE)
yy = 160
for it in items:
round_rect(d, (x + 20, yy, x + 440, yy + 70), 10, SOFT)
text_left(d, (x + 36, yy + 22), it, f_s, DARK)
yy += 85
img.save(OUT / "priority_board.png", quality=95)
print("priority_board.png")
if __name__ == "__main__":
cover_bg()
dual_chain()
feature_panorama()
architecture()
imaging_flow()
state_machine()
er_diagram()
llm_sync()
decision_flow()
transform_arrow()
tech_layers()
scene_cards()
yolo_concept()
module_tree()
closing_bg()
demo_timeline()
priority_board()
print("ALL ASSETS DONE ->", OUT)
+797
View File
@@ -0,0 +1,797 @@
# -*- coding: utf-8 -*-
"""Colorful diagram assets for defense PPT v2."""
from __future__ import annotations
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageFilter
OUT = Path(__file__).resolve().parent / "assets_v2"
OUT.mkdir(parents=True, exist_ok=True)
# Colorful palette
C = {
"navy": (15, 32, 90),
"blue": (37, 99, 235),
"cyan": (6, 182, 212),
"teal": (20, 184, 166),
"green": (34, 197, 94),
"lime": (132, 204, 22),
"yellow": (234, 179, 8),
"orange": (249, 115, 22),
"coral": (251, 113, 133),
"pink": (236, 72, 153),
"purple": (168, 85, 247),
"violet": (139, 92, 246),
"indigo": (99, 102, 241),
"slate": (51, 65, 85),
"dark": (15, 23, 42),
"muted": (100, 116, 139),
"light": (248, 250, 252),
"white": (255, 255, 255),
"soft": (241, 245, 249),
}
def font(size, bold=False):
cands = [
r"C:\Windows\Fonts\msyhbd.ttc" if bold else r"C:\Windows\Fonts\msyh.ttc",
r"C:\Windows\Fonts\simhei.ttf",
r"C:\Windows\Fonts\arial.ttf",
]
for p in cands:
try:
return ImageFont.truetype(p, size)
except OSError:
continue
return ImageFont.load_default()
def rr(d, xy, r, fill, outline=None, w=2):
d.rounded_rectangle(xy, radius=r, fill=fill, outline=outline, width=w)
def tc(d, xy, text, f, fill):
b = d.textbbox((0, 0), text, font=f)
d.text((xy[0] - (b[2] - b[0]) // 2, xy[1] - (b[3] - b[1]) // 2), text, font=f, fill=fill)
def tl(d, xy, text, f, fill):
d.text(xy, text, font=f, fill=fill)
def gradient(w, h, c1, c2, horizontal=False):
img = Image.new("RGB", (w, h), c1)
px = img.load()
for y in range(h):
for x in range(w):
t = (x / max(w - 1, 1)) if horizontal else (y / max(h - 1, 1))
px[x, y] = tuple(int(c1[i] * (1 - t) + c2[i] * t) for i in range(3))
return img
def cover():
w, h = 1920, 1080
img = gradient(w, h, (15, 23, 72), (8, 100, 140))
ov = Image.new("RGBA", (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(ov)
colors = [C["cyan"], C["purple"], C["coral"], C["teal"], C["orange"], C["indigo"]]
for i, (cx, cy, r) in enumerate([(200, 180, 200), (1700, 200, 240), (300, 900, 180), (1600, 880, 220), (960, 540, 380)]):
col = colors[i % len(colors)]
d.ellipse((cx - r, cy - r, cx + r, cy + r), outline=(*col, 70), width=4)
pts = [(280, 300), (520, 220), (800, 400), (1100, 280), (1400, 450), (1650, 320), (1200, 700), (700, 750)]
for i in range(len(pts) - 1):
d.line([pts[i], pts[i + 1]], fill=(*C["cyan"], 90), width=3)
for i, p in enumerate(pts):
col = colors[i % len(colors)]
d.ellipse((p[0] - 10, p[1] - 10, p[0] + 10, p[1] + 10), fill=(*col, 200))
# glass panel
d.rounded_rectangle((160, 200, 1760, 880), radius=36, fill=(10, 25, 60, 150), outline=(*C["cyan"], 120), width=3)
# colorful bars
for i, col in enumerate([C["coral"], C["orange"], C["yellow"], C["teal"], C["blue"], C["purple"]]):
d.rounded_rectangle((220 + i * 250, 780, 440 + i * 250, 820), radius=10, fill=(*col, 220))
img = Image.alpha_composite(img.convert("RGBA"), ov).convert("RGB")
img.save(OUT / "cover.png", quality=95)
def dual_chain():
w, h = 1500, 700
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (40, 20), "双核心业务链路", font(28, True), C["dark"])
chains = [
(70, "链路 A · 医学影像 AI 读片", C["blue"], C["cyan"],
[("影像检查", "X光/CT/MRI/超声"), ("YOLO 检测", "框选+置信度"), ("结构化报告", "所见/印象/建议")]),
(390, "链路 B · 电子病历辅助决策", C["purple"], C["pink"],
[("病历录入", "主诉→诊断→用药"), ("RAG + LLM", "知识检索生成"), ("决策建议", "治疗/护理/随访")]),
]
for y, title, c1, c2, nodes in chains:
rr(d, (30, y, w - 30, y + 280), 20, C["white"], c1, 3)
d.rectangle((30, y, 48, y + 280), fill=c1)
tl(d, (60, y + 18), title, font(24, True), c1)
bw, bh = 360, 120
gap = 40
for i, (a, b) in enumerate(nodes):
x = 70 + i * (bw + gap)
col = c1 if i % 2 == 0 else c2
rr(d, (x, y + 90, x + bw, y + 90 + bh), 16, col)
tc(d, (x + bw // 2, y + 125), a, font(22, True), C["white"])
tc(d, (x + bw // 2, y + 165), b, font(16), (240, 250, 255))
if i < 2:
d.polygon([(x + bw + 8, y + 150), (x + bw + 32, y + 140), (x + bw + 32, y + 160)], fill=c1)
img.save(OUT / "dual_chain.png", quality=95)
def goals_radar():
"""Five goal cards as colorful strip."""
w, h = 1500, 320
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
goals = [
("业务闭环", "登录·患者·影像\n病历·预约·用户", C["blue"]),
("AI 可演示", "YOLO 实检\nLLM/模板降级", C["purple"]),
("前后端分离", "Vue3 + Boot\n+ FastAPI", C["teal"]),
("可离线实训", "默认 H2\nAI 可降级", C["orange"]),
("安全可讲", "JWT·角色\nBCrypt·双端", C["coral"]),
]
for i, (t, desc, col) in enumerate(goals):
x = 25 + i * 295
rr(d, (x, 30, x + 280, 290), 18, C["white"], col, 3)
rr(d, (x, 30, x + 280, 100), 18, col)
d.rectangle((x, 80, x + 280, 100), fill=col)
tc(d, (x + 140, 65), t, font(20, True), C["white"])
for j, ln in enumerate(desc.split("\n")):
tc(d, (x + 140, 150 + j * 36), ln, font(16), C["dark"])
img.save(OUT / "goals.png", quality=95)
def scenes():
w, h = 1500, 380
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
cards = [
("影像科", "上传胸片→AI诊断\n标注框·置信度·报告", C["cyan"]),
("临床医生", "病历关键词→建议\n治疗·护理·随访·RAG", C["blue"]),
("管理员", "LLM配置·知识库\nYOLO权重·用户", C["purple"]),
("挂号窗口", "预约状态流转\n预约→确认→完成", C["orange"]),
]
for i, (t, desc, col) in enumerate(cards):
x = 20 + i * 370
rr(d, (x, 25, x + 350, 350), 20, C["white"], col, 3)
rr(d, (x, 25, x + 350, 110), 20, col)
d.rectangle((x, 90, x + 350, 110), fill=col)
tc(d, (x + 175, 70), t, font(24, True), C["white"])
d.ellipse((x + 145, 135, x + 205, 195), fill=col)
tc(d, (x + 175, 165), str(i + 1), font(22, True), C["white"])
for j, ln in enumerate(desc.split("\n")):
tc(d, (x + 175, 230 + j * 32), ln, font(15), C["dark"])
img.save(OUT / "scenes.png", quality=95)
def pain_transform():
w, h = 1400, 520
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (30, 15), "从教学原型到三端 AI 混合架构", font(24, True), C["dark"])
rr(d, (40, 70, 520, 480), 18, C["white"], C["coral"], 3)
rr(d, (40, 70, 520, 130), 18, C["coral"])
d.rectangle((40, 110, 520, 130), fill=C["coral"])
tc(d, (280, 100), "早期痛点", font(22, True), C["white"])
for i, t in enumerate(["业务割裂 · 缺 360° 档案", "AI 假数据 · 无真实检测", "Thymeleaf 前后端耦合", "Session 权限薄弱", "依赖失败整链中断"]):
tl(d, (70, 160 + i * 55), "● " + t, font(18), C["dark"])
# arrow
d.polygon([(560, 250), (680, 220), (680, 240), (780, 240), (780, 260), (680, 260), (680, 280)], fill=C["teal"])
rr(d, (820, 70, 1360, 480), 18, C["white"], C["teal"], 3)
rr(d, (820, 70, 1360, 130), 18, C["teal"])
d.rectangle((820, 110, 1360, 130), fill=C["teal"])
tc(d, (1090, 100), "当前实现", font(22, True), C["white"])
for i, t in enumerate(["患者 360° 统一视图", "YOLO 实检 + LLM/RAG", "Vue3 SPA + REST", "JWT + 角色双端守卫", "AI 宕机可规则降级"]):
tl(d, (850, 160 + i * 55), "● " + t, font(18), C["dark"])
img.save(OUT / "pain.png", quality=95)
def priority():
w, h = 1500, 780
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (30, 15), "功能需求优先级看板", font(26, True), C["dark"])
cols = [
("P0 必须具备", C["coral"], [
"F-01 登录/改密 JWT+BCrypt",
"F-02 患者 CRUD + 360°",
"F-03 影像 CRUD + 上传",
"F-04 AI 诊断状态机",
"F-05 病历 + 辅助决策",
"F-06 角色权限双端",
]),
("P1 重要增强", C["orange"], [
"F-07 预约挂号全流程",
"F-08 仪表盘可视化",
"F-09 AI 对话助手",
"F-10 知识库 RAG",
"F-11 AI/LLM 配置",
"F-12 YOLO 权重管理",
]),
("P2 体验健壮", C["teal"], [
"F-13 AI 不可用降级",
"F-14 弹窗 append-to-body",
"F-15 报告分段可读",
"F-16 路由过渡体验",
]),
]
for i, (title, col, items) in enumerate(cols):
x = 30 + i * 490
rr(d, (x, 70, x + 460, h - 30), 18, C["white"], col, 3)
rr(d, (x, 70, x + 460, 140), 18, col)
d.rectangle((x, 120, x + 460, 140), fill=col)
tc(d, (x + 230, 105), title, font(22, True), C["white"])
yy = 170
for it in items:
rr(d, (x + 20, yy, x + 440, yy + 70), 12, C["soft"], col, 1)
tl(d, (x + 40, yy + 22), it, font(15), C["dark"])
yy += 85
img.save(OUT / "priority.png", quality=95)
def tech_stack():
w, h = 1500, 620
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (30, 15), "三层技术栈(版本对齐文档)", font(26, True), C["dark"])
layers = [
("前端 SPA :5173", C["blue"], "Vue 3.5.10 · Vite 5.4.8 · Element Plus 2.8.4 · Pinia · Router · Axios · ECharts · marked"),
("业务后端 :8080", C["purple"], "Spring Boot 3.3.4 · Java 17 · Security + jjwt 0.12.6 · JPA · H2/MySQL · Lombok · Maven"),
("AI 微服务 :8001", C["teal"], "FastAPI ≥0.110 · Ultralytics YOLO · OpenCV · LangChain ≥0.2 · DeepSeek 等 OpenAI 兼容"),
]
for i, (t, col, desc) in enumerate(layers):
y = 70 + i * 170
rr(d, (40, y, w - 40, y + 150), 18, C["white"], col, 4)
rr(d, (40, y, 280, y + 150), 18, col)
d.rectangle((220, y, 280, y + 150), fill=col)
tc(d, (160, y + 75), t, font(18, True), C["white"])
tl(d, (310, y + 55), desc, font(16), C["dark"])
img.save(OUT / "tech.png", quality=95)
def architecture():
w, h = 1600, 900
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (40, 20), "系统逻辑架构", font(28, True), C["dark"])
def box(x, y, bw, bh, title, lines, col):
rr(d, (x, y, x + bw, y + bh), 16, C["white"], col, 3)
rr(d, (x, y, x + bw, y + 48), 16, col)
d.rectangle((x, y + 30, x + bw, y + 48), fill=col)
tc(d, (x + bw // 2, y + 24), title, font(18, True), C["white"])
yy = y + 65
for ln in lines:
tc(d, (x + bw // 2, yy), ln, font(14), C["dark"])
yy += 24
box(560, 70, 480, 110, "浏览器 Vue SPA", ["localhost:5173 · Element Plus · ECharts"], C["blue"])
d.line([(800, 180), (800, 230)], fill=C["slate"], width=3)
d.polygon([(800, 240), (790, 225), (810, 225)], fill=C["slate"])
tl(d, (820, 200), "/api Vite 代理", font(14), C["muted"])
box(480, 250, 640, 130, "Spring Boot 业务端", ["8080 · JWT / JPA / 文件 · 鉴权落库 · 降级"], C["purple"])
d.line([(640, 380), (640, 450)], fill=C["blue"], width=3)
d.line([(1000, 380), (1000, 450)], fill=C["teal"], width=3)
d.line([(640, 450), (1000, 450)], fill=C["muted"], width=2)
d.polygon([(640, 465), (630, 450), (650, 450)], fill=C["blue"])
d.polygon([(1000, 465), (990, 450), (1010, 450)], fill=C["teal"])
box(280, 480, 420, 150, "H2 / MySQL", ["默认 H2 内存 · 可选 MySQL", "DataInitializer 种子数据"], C["orange"])
box(900, 480, 480, 170, "FastAPI AI 服务", ["8001 · YOLO · RAG · LLM", "失败可降级到模板规则"], C["teal"])
box(820, 720, 200, 100, "权重 pt", ["best.pt 等"], C["indigo"])
box(1040, 720, 200, 100, "知识 MD", ["高血压/肺炎…"], C["pink"])
box(1260, 720, 220, 100, "外部 LLM", ["DeepSeek 兼容"], C["green"])
d.line([(1140, 650), (920, 720)], fill=C["muted"], width=2)
d.line([(1140, 650), (1140, 720)], fill=C["muted"], width=2)
d.line([(1140, 650), (1370, 720)], fill=C["muted"], width=2)
rr(d, (40, 700, 760, 860), 14, C["white"], C["violet"], 2)
tl(d, (60, 720), "调用原则", font(18, True), C["violet"])
for i, p in enumerate(["1. 浏览器只访问业务后端", "2. AI 能力集中在 FastAPI", "3. 配置单向同步管理端→AI", "4. 失败可降级保证演示"]):
tl(d, (60, 760 + i * 24), p, font(14), C["dark"])
img.save(OUT / "architecture.png", quality=95)
def feature_panorama():
w, h = 1500, 800
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (30, 15), "智慧医院功能全景", font(26, True), C["dark"])
cols = [
("身份与权限", C["blue"], ["登录 / JWT", "修改密码", "角色菜单", "头像上传"]),
("业务主数据", C["purple"], ["患者管理", "用户管理", "知识库文档"]),
("诊疗业务", C["teal"], ["影像检查", "电子病历", "预约挂号", "仪表盘统计"]),
("智能能力", C["coral"], ["YOLO 影像检测", "AI 诊断报告", "EMR 决策+RAG", "AI 对话助手", "LLM/YOLO 管理"]),
]
for i, (title, col, items) in enumerate(cols):
x = 30 + i * 370
rr(d, (x, 70, x + 350, h - 30), 18, C["white"], col, 3)
rr(d, (x, 70, x + 350, 140), 18, col)
d.rectangle((x, 120, x + 350, 140), fill=col)
tc(d, (x + 175, 105), title, font(20, True), C["white"])
yy = 170
for it in items:
rr(d, (x + 20, yy, x + 330, yy + 70), 12, C["soft"])
d.ellipse((x + 40, yy + 22, x + 66, yy + 48), fill=col)
tl(d, (x + 80, yy + 22), it, font(16), C["dark"])
yy += 85
img.save(OUT / "features.png", quality=95)
def _arrow(d, x1, y1, x2, y2, fill, width=3):
"""Orthogonal-friendly straight segment with arrow head at end."""
d.line([(x1, y1), (x2, y2)], fill=fill, width=width)
# arrow head
if x2 == x1 and y2 > y1: # down
d.polygon([(x2, y2), (x2 - 7, y2 - 12), (x2 + 7, y2 - 12)], fill=fill)
elif x2 == x1 and y2 < y1: # up
d.polygon([(x2, y2), (x2 - 7, y2 + 12), (x2 + 7, y2 + 12)], fill=fill)
elif y2 == y1 and x2 > x1: # right
d.polygon([(x2, y2), (x2 - 12, y2 - 7), (x2 - 12, y2 + 7)], fill=fill)
elif y2 == y1 and x2 < x1: # left
d.polygon([(x2, y2), (x2 + 12, y2 - 7), (x2 + 12, y2 + 7)], fill=fill)
def _ortho(d, path, fill, width=3, arrow=True):
"""Draw polyline path [(x,y),...]; optional arrow on last segment."""
for i in range(len(path) - 1):
x1, y1 = path[i]
x2, y2 = path[i + 1]
if i == len(path) - 2 and arrow:
_arrow(d, x1, y1, x2, y2, fill, width)
else:
d.line([(x1, y1), (x2, y2)], fill=fill, width=width)
def imaging_flow():
"""Left-to-right pipeline with orthogonal connectors only."""
w, h = 1600, 880
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (30, 15), "AI 影像诊断核心流程", font(26, True), C["dark"])
# notes card top-right (no lines through it)
rr(d, (980, 50, 1560, 220), 14, C["white"], C["violet"], 2)
tl(d, (1000, 65), "技术要点", font(18, True), C["violet"])
for i, t in enumerate([
"· AsyncConfig 线程池,避免阻塞 HTTP",
"· 前端轮询有上限,卸载时清理",
"· AI 不可用 → Spring 本地规则降级",
"· 报告分段:所见 / 印象 / 建议 / 声明",
]):
tl(d, (1000, 105 + i * 28), t, font(14), C["dark"])
# Compact pipeline with guaranteed gaps between boxes
bh = 88
# (id, x, y, w, text, color)
spec = [
(0, 40, 300, 175, "新建影像\n上传图片", C["blue"]),
(1, 255, 300, 155, "PENDING", C["muted"]),
(2, 450, 300, 170, "点击AI诊断", C["orange"]),
(3, 660, 300, 170, "ANALYZING", C["coral"]),
(4, 870, 300, 170, "Spring\n@Async", C["purple"]),
(5, 1080, 300, 180, "FastAPI\nanalyze", C["teal"]),
(6, 900, 500, 180, "YOLO 检测\n标注图", C["indigo"]),
(7, 1160, 500, 180, "报告生成\nLLM/模板", C["cyan"]),
(8, 780, 700, 180, "写结果\n更新记录", C["green"]),
(9, 1020, 700, 190, "COMPLETED\n/ ERROR", C["green"]),
(10, 1270, 700, 200, "前端轮询\n报告弹窗", C["blue"]),
]
boxes = {}
for i, x, y, wbox, text, col in spec:
rr(d, (x, y, x + wbox, y + bh), 14, col)
lines = text.split("\n")
for j, ln in enumerate(lines):
tc(d, (x + wbox // 2, y + (bh // 2 - 12) + j * 26), ln, font(14, True), C["white"])
boxes[i] = (x, y, wbox, bh)
def mid_right(i):
x, y, wbox, bh_ = boxes[i]
return x + wbox, y + bh_ // 2
def mid_left(i):
x, y, wbox, bh_ = boxes[i]
return x, y + bh_ // 2
def mid_bottom(i):
x, y, wbox, bh_ = boxes[i]
return x + wbox // 2, y + bh_
def mid_top(i):
x, y, wbox, bh_ = boxes[i]
return x + wbox // 2, y
# main chain
for a, b in [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)]:
x1, y1 = mid_right(a)
x2, y2 = mid_left(b)
gap = x2 - x1
if gap > 16:
_arrow(d, x1 + 3, y1, x2 - 3, y2, C["slate"], 3)
# FastAPI -> split bus -> YOLO / Report
fx, fy = mid_bottom(5)
bus_y = 450
d.line([(fx, fy), (fx, bus_y)], fill=C["slate"], width=3)
yx, _ = mid_top(6)
rx, _ = mid_top(7)
d.line([(min(yx, fx), bus_y), (max(rx, fx), bus_y)], fill=C["slate"], width=3)
_arrow(d, yx, bus_y, yx, mid_top(6)[1] - 2, C["slate"], 3)
_arrow(d, rx, bus_y, rx, mid_top(7)[1] - 2, C["slate"], 3)
# merge YOLO + Report -> 写结果
merge_y = 640
d.line([(mid_bottom(6)[0], mid_bottom(6)[1]), (mid_bottom(6)[0], merge_y)], fill=C["slate"], width=3)
d.line([(mid_bottom(7)[0], mid_bottom(7)[1]), (mid_bottom(7)[0], merge_y)], fill=C["slate"], width=3)
d.line([(mid_top(8)[0], merge_y), (mid_bottom(7)[0], merge_y)], fill=C["slate"], width=3)
_arrow(d, mid_top(8)[0], merge_y, mid_top(8)[0], mid_top(8)[1] - 2, C["slate"], 3)
# bottom chain
for a, b in [(8, 9), (9, 10)]:
x1, y1 = mid_right(a)
x2, y2 = mid_left(b)
if x2 - x1 > 12:
_arrow(d, x1 + 3, y1, x2 - 3, y2, C["slate"], 3)
img.save(OUT / "imaging_flow.png", quality=95)
def decision_flow():
w, h = 1500, 500
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (30, 15), "电子病历辅助决策流程", font(24, True), C["dark"])
nodes = [
("保存病历", C["blue"]),
("Decision\nSupport", C["purple"]),
("FastAPI\n/report/decision", C["teal"]),
("RAG+LLM\n或模板回退", C["orange"]),
("七类建议\nDialog 展示", C["coral"]),
]
boxes = []
for i, (t, col) in enumerate(nodes):
x = 40 + i * 290
rr(d, (x, 70, x + 250, 200), 16, col)
for j, ln in enumerate(t.split("\n")):
tc(d, (x + 125, 120 + j * 30), ln, font(16, True), C["white"])
boxes.append((x, 70, 250, 130))
if i < 4:
# edge-to-edge arrow in the gap only
x1 = x + 250 + 6
x2 = x + 290 - 6
midy = 70 + 65
_arrow(d, x1, midy, x2, midy, C["slate"], 3)
# vertical from last node down to output row bus
last = boxes[-1]
lx = last[0] + last[2] // 2
d.line([(lx, last[1] + last[3]), (lx, 300)], fill=C["slate"], width=3)
d.line([(50, 300), (1450, 300)], fill=C["slate"], width=3)
outs = [("治疗", C["blue"]), ("用药", C["purple"]), ("护理", C["teal"]), ("随访", C["green"]),
("风险", C["orange"]), ("冲突", C["coral"]), ("RAG", C["indigo"])]
for i, (o, col) in enumerate(outs):
x = 50 + i * 205
_arrow(d, x + 90, 300, x + 90, 330, C["slate"], 2)
rr(d, (x, 335, x + 180, 450), 12, col)
tc(d, (x + 90, 392), o, font(16, True), C["white"])
img.save(OUT / "decision.png", quality=95)
def state_machine():
w, h = 1400, 280
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (30, 15), "影像诊断状态机", font(22, True), C["dark"])
states = [
("PENDING", "已登记待诊断", C["muted"]),
("ANALYZING", "诊断进行中", C["orange"]),
("COMPLETED", "成功可看报告", C["green"]),
("ERROR", "失败", C["coral"]),
]
for i, (n, desc, col) in enumerate(states):
x = 50 + i * 340
rr(d, (x, 70, x + 280, 220), 18, col)
tc(d, (x + 140, 120), n, font(20, True), C["white"])
tc(d, (x + 140, 170), desc, font(14), C["white"])
if i < 3:
d.polygon([(x + 290, 140), (x + 325, 130), (x + 325, 150)], fill=C["slate"])
img.save(OUT / "state.png", quality=95)
def yolo_concept():
w, h = 1000, 560
img = Image.new("RGB", (w, h), (20, 28, 48))
d = ImageDraw.Draw(img)
tc(d, (250, 30), "原始影像(示意)", font(18, True), C["cyan"])
tc(d, (750, 30), "YOLO 标注(示意)", font(18, True), C["coral"])
rr(d, (40, 60, 460, 500), 12, (30, 40, 60))
rr(d, (540, 60, 960, 500), 12, (30, 40, 60))
d.ellipse((90, 120, 220, 400), outline=(160, 190, 210), width=2)
d.ellipse((250, 120, 380, 400), outline=(160, 190, 210), width=2)
d.ellipse((590, 120, 720, 400), outline=(160, 190, 210), width=2)
d.ellipse((750, 120, 880, 400), outline=(160, 190, 210), width=2)
d.rectangle((600, 180, 700, 270), outline=C["teal"], width=3)
tl(d, (600, 155), "findings 0.87", font(14), C["teal"])
d.rectangle((780, 250, 870, 330), outline=C["orange"], width=3)
tl(d, (760, 225), "nodule 0.76", font(14), C["orange"])
tl(d, (40, 520), "仅教学演示概念图,非真实患者数据", font(13), C["muted"])
img.save(OUT / "yolo.png", quality=95)
def team_overview():
"""Six members in 2x3 grid — orthogonal tree, no diagonals through cards."""
w, h = 1500, 740
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (30, 12), "六人协作总览(按工程模块划分)", font(26, True), C["dark"])
# hub
hub = (450, 50, 1050, 115)
rr(d, hub, 16, C["navy"])
tc(d, (750, 82), "Smart Hospital · 6 人三端协作", font(20, True), C["white"])
# column centers and card geometry
cols_x = [40, 520, 1000] # left of cards
card_w, card_h = 460, 200
top_y, bot_y = 175, 460
cxs = [x + card_w // 2 for x in cols_x] # 270, 750, 1230
# tree connectors (draw first, under cards visually by being outside card bodies)
# hub bottom center down to bus
bus_y = 145
d.line([(750, 115), (750, bus_y)], fill=C["slate"], width=3)
d.line([(cxs[0], bus_y), (cxs[2], bus_y)], fill=C["slate"], width=3)
for cx in cxs:
d.line([(cx, bus_y), (cx, top_y)], fill=C["slate"], width=3)
# vertical between top and bottom cards in each column
for cx, x in zip(cxs, cols_x):
d.line([(cx, top_y + card_h), (cx, bot_y)], fill=C["slate"], width=3)
members = [
(cols_x[0], top_y, "成员A", "前端基础", "布局 · 登录 · 权限 · Pinia", C["blue"], "前端层"),
(cols_x[1], top_y, "成员B", "前端业务", "仪表盘 · 患者360° · 预约", C["cyan"], "前端层"),
(cols_x[2], top_y, "成员C", "前端智能", "影像 · 病历决策 · AI助手", C["purple"], "前端层"),
(cols_x[0], bot_y, "成员D", "后端主数据", "JWT · 患者 · 用户 · 预约", C["orange"], "业务后端"),
(cols_x[1], bot_y, "成员E", "后端 AI 桥", "异步诊断 · Client · 降级", C["coral"], "业务后端"),
(cols_x[2], bot_y, "成员F", "AI 微服务", "YOLO · RAG · LLM · 权重", C["teal"], "AI 服务"),
]
for x, y, name, role, desc, col, layer in members:
rr(d, (x, y, x + card_w, y + card_h), 18, C["white"], col, 3)
rr(d, (x, y, x + card_w, y + 58), 18, col)
d.rectangle((x, y + 42, x + card_w, y + 58), fill=col)
tc(d, (x + card_w // 2, y + 30), f"{name} · {role}", font(17, True), C["white"])
tc(d, (x + card_w // 2, y + 105), desc, font(15), C["dark"])
rr(d, (x + 140, y + 145, x + 320, y + 178), 10, col)
tc(d, (x + card_w // 2, y + 161), layer, font(13, True), C["white"])
tl(d, (40, 690), "树状正交连线:总枢纽 → 三列 → 上下两排;连线只走卡片外空隙,不穿过文字", font(14), C["muted"])
img.save(OUT / "team.png", quality=95)
def team_swimlane():
"""Wide short swimlane (fits full PPT width without looking tiny)."""
w, h = 1600, 360 # ~4.4:1 — scales to full slide width
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (20, 8), "六人分工泳道 · 交付物对照(全宽)", font(20, True), C["dark"])
rows = [
("A 前端基础", C["blue"], "BasicLayout · 登录 · 路由守卫 · Pinia · 个人中心"),
("B 前端业务", C["cyan"], "Dashboard · Patients 360° · Appointments · ECharts"),
("C 前端智能", C["purple"], "Imaging 报告弹窗 · EMR 决策 Dialog · AI 助手 · 知识库页"),
("D 后端主数据", C["orange"], "Security/JWT · User · Patient · Appointment · Result"),
("E 后端 AI 桥", C["coral"], "AIDiagnosis 异步 · AiServiceClient · 决策对接 · 降级"),
("F AI 微服务", C["teal"], "YOLO · /imaging/analyze · RAG · LLM · 权重/配置"),
]
row_h, gap, top = 48, 6, 42
for i, (name, col, desc) in enumerate(rows):
y = top + i * (row_h + gap)
rr(d, (20, y, 280, y + row_h), 10, col)
tc(d, (150, y + row_h // 2), name, font(15, True), C["white"])
rr(d, (295, y, 1580, y + row_h), 10, C["white"], col, 2)
tl(d, (315, y + 12), desc, font(15), C["dark"])
img.save(OUT / "team_lane.png", quality=95)
def module_tree():
w, h = 1400, 620
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (30, 15), "仓库三工程结构", font(24, True), C["dark"])
roots = [
(40, "frontend/", C["blue"], ["api/ HTTP", "views/ 页面", "router 守卫", "stores Pinia"]),
(480, "smart-hospital/", C["purple"], ["controller/service", "security JWT", "uploads 文件", "data/ai 配置"]),
(920, "ai-service/", C["teal"], ["api imaging/rag", "yolo · llm", "knowledge MD", "weights pt"]),
]
for x, title, col, items in roots:
rr(d, (x, 70, x + 400, 580), 18, C["white"], col, 3)
rr(d, (x, 70, x + 400, 140), 18, col)
d.rectangle((x, 120, x + 400, 140), fill=col)
tc(d, (x + 200, 105), title, font(20, True), C["white"])
yy = 170
for it in items:
rr(d, (x + 30, yy, x + 370, yy + 80), 12, C["soft"])
tl(d, (x + 55, yy + 25), it, font(16), C["dark"])
yy += 95
img.save(OUT / "modules.png", quality=95)
def demo_path():
w, h = 1500, 300
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (20, 10), "推荐演示剧本(7 步)", font(20, True), C["dark"])
steps = [
("1", "doctor1\n登录", C["blue"]),
("2", "患者\n360°", C["cyan"]),
("3", "影像\nAI诊断", C["purple"]),
("4", "病历\n决策", C["teal"]),
("5", "预约\n流转", C["orange"]),
("6", "admin\n配置", C["coral"]),
("7", "降级\n验证", C["indigo"]),
]
for i, (n, t, col) in enumerate(steps):
x = 40 + i * 210
d.ellipse((x + 55, 55, x + 115, 115), fill=col)
tc(d, (x + 85, 85), n, font(20, True), C["white"])
for j, ln in enumerate(t.split("\n")):
tc(d, (x + 85, 140 + j * 28), ln, font(14), C["dark"])
if i < 6:
d.line([(x + 125, 85), (x + 195, 85)], fill=C["muted"], width=3)
img.save(OUT / "demo.png", quality=95)
def closing():
w, h = 1920, 1080
img = gradient(w, h, (30, 20, 80), (10, 90, 120))
ov = Image.new("RGBA", (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(ov)
for i, col in enumerate([C["cyan"], C["purple"], C["coral"], C["teal"], C["orange"], C["blue"]]):
ang = i * 60
import math
cx = 960 + int(280 * math.cos(math.radians(ang)))
cy = 480 + int(200 * math.sin(math.radians(ang)))
d.ellipse((cx - 40, cy - 40, cx + 40, cy + 40), fill=(*col, 160))
d.ellipse((860, 380, 1060, 580), outline=(*C["cyan"], 180), width=5)
d.rounded_rectangle((940, 430, 980, 530), radius=6, fill=(*C["teal"], 220))
d.rounded_rectangle((910, 460, 1010, 500), radius=6, fill=(*C["teal"], 220))
img = Image.alpha_composite(img.convert("RGBA"), ov).convert("RGB")
img.save(OUT / "closing.png", quality=95)
def nfr_icons():
w, h = 1500, 280
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
items = [
("性能", "@Async 异步\n有限轮询", C["blue"]),
("可用性", "H2 开箱\n种子数据", C["teal"]),
("安全", "JWT·BCrypt\nCORS", C["purple"]),
("可维护", "Result<T>\n全局异常", C["orange"]),
("可扩展", "AI 独立\n进程解耦", C["coral"]),
("兼容", "OpenAI\n协议可切换", C["indigo"]),
]
for i, (t, desc, col) in enumerate(items):
x = 20 + i * 245
rr(d, (x, 25, x + 230, 250), 16, C["white"], col, 3)
rr(d, (x, 25, x + 230, 90), 16, col)
d.rectangle((x, 70, x + 230, 90), fill=col)
tc(d, (x + 115, 55), t, font(18, True), C["white"])
for j, ln in enumerate(desc.split("\n")):
tc(d, (x + 115, 130 + j * 35), ln, font(14), C["dark"])
img.save(OUT / "nfr.png", quality=95)
def er_mini():
"""
Clean ER with orthogonal bus connectors — lines never cross through card text.
Layout (文档 8.1 / 8.4):
User
______|______
| | |
Imaging EMR Appointment
| | |
+------+------+
|
Patient
Imaging
|
AIResult
"""
w, h = 1200, 640
img = Image.new("RGB", (w, h), C["light"])
d = ImageDraw.Draw(img)
tl(d, (24, 12), "核心实体关系(正交连线)", font(22, True), C["dark"])
def ent(x, y, bw, title, fields, col):
bh = 44 + 22 * len(fields) + 14
rr(d, (x, y, x + bw, y + bh), 12, C["white"], col, 2)
rr(d, (x, y, x + bw, y + 40), 12, col)
d.rectangle((x, y + 28, x + bw, y + 40), fill=col)
tc(d, (x + bw // 2, y + 20), title, font(16, True), C["white"])
yy = y + 52
for f in fields:
tl(d, (x + 16, yy), f, font(13), C["dark"])
yy += 22
# return box edges
return {"x": x, "y": y, "w": bw, "h": bh, "cx": x + bw // 2, "cy": y + bh // 2,
"top": (x + bw // 2, y), "bottom": (x + bw // 2, y + bh),
"left": (x, y + bh // 2), "right": (x + bw, y + bh // 2)}
BW = 220
user = ent(490, 50, BW, "User", ["ADMIN/DOCTOR/RADIO", "BCrypt 密码"], C["purple"])
imaging = ent(120, 250, BW, "Imaging", ["类型/状态", "图像 URL"], C["teal"])
emr = ent(490, 250, BW, "EMR", ["主诉→随访", "决策关联"], C["orange"])
appt = ent(860, 250, BW, "Appointment", ["状态机", "按日筛选"], C["indigo"])
patient = ent(490, 450, BW, "Patient", ["档案字段", "既往史"], C["blue"])
ai = ent(120, 450, BW, "AIResult", ["置信度/检测框", "完整报告"], C["coral"])
line_c = C["slate"]
# User bottom -> horizontal bus -> drop to each mid entity top
bus_y = 200
ux, uy = user["bottom"]
d.line([(ux, uy), (ux, bus_y)], fill=line_c, width=3)
# bus spans imaging.cx to appt.cx
d.line([(imaging["cx"], bus_y), (appt["cx"], bus_y)], fill=line_c, width=3)
for box in (imaging, emr, appt):
_arrow(d, box["cx"], bus_y, box["cx"], box["top"][1] - 2, line_c, 3)
# Mid entities bottom -> lower bus -> Patient top
bus2_y = 400
for box in (imaging, emr, appt):
d.line([(box["cx"], box["bottom"][1]), (box["cx"], bus2_y)], fill=line_c, width=3)
d.line([(imaging["cx"], bus2_y), (appt["cx"], bus2_y)], fill=line_c, width=3)
_arrow(d, patient["cx"], bus2_y, patient["cx"], patient["top"][1] - 2, line_c, 3)
# Imaging → AIResult: continuous vertical (bus2 crosses same x — OK for tree)
ix = imaging["cx"]
d.line([(ix, imaging["bottom"][1]), (ix, ai["top"][1] - 2)], fill=line_c, width=3)
_arrow(d, ix, ai["top"][1] - 14, ix, ai["top"][1] - 2, line_c, 3)
# 1:1 label placed to the LEFT of the stem, clear of bus
tl(d, (ix - 48, (imaging["bottom"][1] + bus2_y) // 2 - 6), "1:1", font(12, True), C["coral"])
# relationship captions on buses (right side, clear of arrows)
tl(d, (740, 178), "医生创建", font(12), C["muted"])
tl(d, (740, 378), "归属患者", font(12), C["muted"])
# legend
rr(d, (860, 480, 1160, 600), 12, C["white"], C["soft"], 2)
tl(d, (880, 495), "关系说明", font(14, True), C["dark"])
tl(d, (880, 525), "User → 影像/病历/预约", font(12), C["muted"])
tl(d, (880, 550), "Patient ← 三类业务记录", font(12), C["muted"])
tl(d, (880, 575), "Imaging → AIResult (1:1)", font(12), C["muted"])
img.save(OUT / "er.png", quality=95)
if __name__ == "__main__":
cover()
dual_chain()
goals_radar()
scenes()
pain_transform()
priority()
tech_stack()
architecture()
feature_panorama()
imaging_flow()
decision_flow()
state_machine()
yolo_concept()
team_overview()
team_swimlane()
module_tree()
demo_path()
closing()
nfr_icons()
er_mini()
print("assets_v2 done ->", OUT)
Binary file not shown.
Binary file not shown.
Binary file not shown.
+775
View File
@@ -0,0 +1,775 @@
# 智慧医院 AI 影像诊断与电子病历辅助决策系统
## 项目详细文档
| 项目 | 说明 |
|------|------|
| 项目名称 | 智慧医院 AI 影像诊断与电子病历辅助决策系统(Smart Hospital) |
| 项目类型 | 高校/实训教学演示级 Web 应用 |
| 文档版本 | 1.0 |
| 文档日期 | 2026-07-27 |
| 代码根目录 | `smart-hospital/` |
---
## 目录
1. [项目主题](#一项目主题)
2. [需求分析](#二需求分析)
3. [项目功能](#三项目功能)
4. [技术栈](#四技术栈)
5. [系统架构](#五系统架构)
6. [目录与模块结构](#六目录与模块结构)
7. [核心业务流程](#七核心业务流程)
8. [数据模型概要](#八数据模型概要)
9. [接口与权限](#九接口与权限)
10. [部署与运行](#十部署与运行)
11. [演示账号与推荐路径](#十一演示账号与推荐路径)
12. [设计说明与边界](#十二设计说明与边界)
---
## 一、项目主题
### 1.1 主题定位
本项目以 **「智慧医院」** 为主题,围绕医院日常诊疗中的两条核心链路展开:
1. **医学影像检查 → AI 辅助读片 → 结构化诊断报告**
2. **电子病历录入 → AI 辅助决策(治疗 / 用药 / 护理 / 随访)→ 知识库引用**
在传统 HIS(医院信息系统)业务能力之上,引入 **计算机视觉(YOLO)** 与 **大语言模型 / RAG 知识检索**,形成「业务系统 + AI 微服务」的混合架构,用于实训教学、课程答辩与功能演示。
### 1.2 建设目标
| 目标 | 说明 |
|------|------|
| 业务闭环 | 覆盖登录鉴权、患者档案、影像检查、病历、预约挂号、用户管理等完整业务面 |
| AI 可演示 | 影像侧可真实跑 YOLO 权重检测;报告与决策侧可接 DeepSeek 等 OpenAI 兼容大模型,也可模板降级 |
| 前后端分离 | Vue 3 SPA + Spring Boot REST + FastAPI AI 服务,职责清晰、便于分模块讲解 |
| 可离线实训 | 默认 H2 内存库,无需强制安装 MySQL;AI 服务不可用时业务仍可降级运行 |
| 安全可讲 | JWT 无状态认证、角色权限(ADMIN / DOCTOR / RADIOLOGIST)、BCrypt 密码、前后端双重路由守卫 |
### 1.3 应用场景(教学/演示)
- 影像科:上传胸片等影像 → 一键 AI 诊断 → 查看 YOLO 标注框、置信度与分段报告
- 临床医生:书写病历(如含「高血压 / 肺炎 / 糖尿病 / 结节」等关键词)→ 获取治疗、护理、随访建议与知识库来源
- 管理员:配置 LLM 接口、管理知识库文档、切换/上传 YOLO 权重、管理系统用户
- 挂号窗口:预约登记与状态流转(预约 → 确认 → 完成 / 取消 / 未到诊)
### 1.4 项目声明
> 本系统输出内容 **仅供教学实训与辅助决策演示**,**不能替代执业医师的正式诊断与医疗文书**。涉及真实患者数据与临床部署时,需另行满足医疗信息化、隐私与合规要求。
---
## 二、需求分析
### 2.1 背景与问题
传统教学型医院管理系统往往只做 CRUD 与简单页面,存在以下不足:
| 问题 | 表现 |
|------|------|
| 业务割裂 | 患者、影像、病历、预约缺少统一档案视图 |
| AI 仅「假数据」 | 随机文案或关键字匹配,无法展示真实检测框与模型链路 |
| 技术栈陈旧 | 早期版本以 Thymeleaf 服务端渲染为主,前后端耦合 |
| 权限薄弱 | 仅 Session 判断登录,缺少角色级接口保护 |
| 不可降级 | 外部依赖一旦失败,整条演示链路中断 |
本项目在原始 Spring Boot + Thymeleaf 原型基础上完成改造,形成当前 **前后端分离 + AI 微服务** 版本,以解决上述问题。
### 2.2 用户角色与诉求
| 角色 | 代码枚举 | 核心诉求 |
|------|----------|----------|
| 系统管理员 | `ADMIN` | 用户管理、AI 大模型配置、YOLO 权重管理、全局运维 |
| 临床医生 | `DOCTOR` | 患者与病历、辅助决策、预约、查看影像报告 |
| 影像医师 | `RADIOLOGIST` | 影像登记、上传、触发 AI 诊断、审阅标注图与报告 |
| 访客/未登录 | — | 仅可访问登录页 |
### 2.3 功能需求(按优先级)
#### P0 — 必须具备
| 编号 | 需求 | 验收要点 |
|------|------|----------|
| F-01 | 用户登录 / 登出 / 修改密码 | JWT 签发与校验;密码 BCrypt;修改后需重新登录 |
| F-02 | 患者档案 CRUD + 搜索分页 | 姓名等关键字;360° 档案关联影像 / 病历 / 预约 |
| F-03 | 影像检查 CRUD + 文件上传 | 支持 X_RAY / CT / MRI / ULTRASOUND |
| F-04 | AI 影像诊断 | 异步状态机 `PENDING → ANALYZING → COMPLETED / ERROR`;可查看结果 |
| F-05 | 电子病历 CRUD + AI 辅助决策 | 治疗 / 用药 / 护理 / 随访 / 风险 / 冲突 / RAG 来源 |
| F-06 | 角色权限控制 | 前端路由 `meta.roles` + 后端 `@PreAuthorize` |
#### P1 — 重要增强
| 编号 | 需求 | 验收要点 |
|------|------|----------|
| F-07 | 预约挂号全流程 | 状态流转、按日筛选、统计卡片 |
| F-08 | 仪表盘可视化 | KPI、近 7 日趋势、检查类型/诊断状态分布 |
| F-09 | AI 对话助手 | 多轮对话、历史持久化(文件)、Markdown 渲染 |
| F-10 | 知识库管理 | 文档增删改查,供 RAG / 决策引用 |
| F-11 | AI 配置(管理员) | LLM Base URL / API Key / Model 配置,并同步至 FastAPI |
| F-12 | YOLO 权重管理(管理员) | 权重列表、激活、上传统计 |
#### P2 — 体验与健壮性
| 编号 | 需求 | 验收要点 |
|------|------|----------|
| F-13 | AI 服务不可用时业务降级 | Spring 侧本地规则模拟,页面仍可演示 |
| F-14 | 弹窗 / 抽屉不被布局裁切 | `append-to-body`、限高滚动、表单左右留白协调 |
| F-15 | 报告分段可读 | 影像所见 / 诊断印象 / 建议 / 声明分块展示 |
| F-16 | 页面切换过渡与仪表盘体验 | 路由过渡、半透明卡片叠背景图等 |
### 2.4 非功能需求
| 类别 | 要求 | 项目落地 |
|------|------|----------|
| 性能 | 诊断异步,避免阻塞 HTTP 线程 | `@Async` + 线程池;前端轮询(有上限,卸载时清理) |
| 可用性 | 无 MySQL 亦可演示 | 默认 H2 内存库 + `DataInitializer` 种子数据 |
| 安全 | 接口鉴权、密码加密、CORS 白名单 | Spring Security + JWT;`cors.allowed-origins` |
| 可维护 | 统一响应与异常 | `Result<T>` + `GlobalExceptionHandler` |
| 可扩展 | AI 与业务解耦 | FastAPI 独立进程;配置 `ai.service.base-url` |
| 兼容 | 大模型厂商可切换 | OpenAI 兼容协议(DeepSeek / Qwen 等) |
### 2.5 约束与假设
- 面向 **实训 / 课程设计 / 答辩演示**,非生产级 HIS 或 PACS 替代品。
- 医学影像以常见图片格式为主(JPG / PNG 等),DICOM 完整工作流未作为重点。
- YOLO 权重与检测类别为演示级配置,输出置信度与框选结果需医师复核。
- H2 内存模式下进程退出即丢库内业务数据;AI 配置与对话历史另存 `./data/ai/` 文件,可跨重启保留。
---
## 三、项目功能
### 3.1 功能总览
```
┌─────────────────────────────────────────────────────────────────┐
│ 智慧医院功能全景 │
├──────────────┬──────────────┬──────────────┬────────────────────┤
│ 身份与权限 │ 业务主数据 │ 诊疗业务 │ 智能能力 │
├──────────────┼──────────────┼──────────────┼────────────────────┤
│ 登录 / JWT │ 患者管理 │ 影像检查 │ YOLO 影像检测 │
│ 修改密码 │ 用户管理 │ 电子病历 │ AI 诊断报告 │
│ 角色菜单 │ 知识库文档 │ 预约挂号 │ EMR 辅助决策 + RAG │
│ 头像上传 │ │ 仪表盘统计 │ AI 对话助手 │
│ │ │ │ LLM / YOLO 管理端 │
└──────────────┴──────────────┴──────────────┴────────────────────┘
```
### 3.2 功能模块详述
#### 3.2.1 登录与个人中心
- 账号密码登录,返回 JWT 与用户信息,前端 `localStorage` 持久化(约 24h 过期,见 `jwt.expiration-ms`)。
- 右上角用户菜单:修改密码、更换头像。
- 未登录访问业务页自动跳转登录;已登录访问登录页跳转仪表盘。
#### 3.2.2 仪表盘(Dashboard)
- 多项 KPI:患者数、影像数、病历数、预约数及状态分布等。
- 近 7 天业务趋势折线 / 柱状图(ECharts)。
- 检查类型分布、诊断状态分布饼图。
- 快捷入口与待办提示,便于演示导览。
#### 3.2.3 患者管理
- 分页列表、关键字搜索(姓名等)。
- 新增 / 编辑 / 删除:姓名、性别、年龄、身份证、电话、地址、既往病史。
- **患者 360° 档案**(抽屉):基本信息 + 关联影像记录、电子病历、预约列表及计数统计。
- 表单弹窗采用顶栏标签与等宽两列布局,避免左右留白不均。
#### 3.2.4 影像诊断
- 检查登记:选择患者、检查类型(X 光 / CT / MRI / 超声)、部位、上传影像或填写路径。
- 列表筛选:关键字、状态、检查类型;状态 KPI 卡片可快速过滤。
- **AI 诊断**:触发后状态流转;完成后展示:
- 诊断印象与置信度仪表盘
- 原始影像 vs YOLO 标注图对比
- 影像所见(分段段落)
- 建议(编号列表)
- 检测明细表(类别、置信度、bbox)
- 完整报告(报告头 / 所见 / 印象 / 建议 / 声明 分块卡片)
- AI 服务不可用时,业务后端可降级为本地规则结果,保证演示不断链。
#### 3.2.5 电子病历
- 病历录入:患者、就诊日期、主诉、现病史、体格检查、诊断、治疗方案、用药、随访。
- 保存后可自动弹出 **AI 辅助决策**;列表亦可再次查看。
- 决策内容包括:治疗建议表、用药建议表、护理建议、随访计划、风险评估、药物冲突提示、知识库引用(RAG)。
- 诊断含「高血压 / 糖尿病 / 肺炎 / 结节」等关键词时,模板/RAG 效果更明显;配置 LLM 后由大模型增强。
#### 3.2.6 预约挂号
- 新建 / 编辑 / 删除预约。
- 状态流转:预约 → 确认 → 完成 / 取消 / 未到诊。
- 按日期筛选、统计卡片、详情抽屉。
#### 3.2.7 AI 助手
- 对话式问答界面,支持 Markdown 渲染(`marked` + `DOMPurify` 消毒)。
- 对话历史持久化到业务端 `./data/ai/chat-history.json`(不依赖 H2 是否清空)。
#### 3.2.8 知识库
- 知识文档的新增、编辑、查看、启用/停用。
- 与 AI 服务 RAG 管线配合;AI 服务内置医学相关 Markdown 知识片段(如高血压、肺炎、肺结节、糖尿病等)。
#### 3.2.9 AI 配置(仅管理员)
- 配置 OpenAI 兼容接口:Base URL、API Key、Model 等。
- 设置持久化到 `./data/ai/settings.json`。
- 启动或保存时通过同步机制推送到 FastAPI(`llm_config`),使报告生成与决策走大模型而非纯模板。
#### 3.2.10 YOLO 权重管理(仅管理员)
- 查看当前权重、模式(real / demo)、推理统计。
- 上传 / 激活权重文件,支撑影像检测能力切换。
#### 3.2.11 用户管理(仅管理员)
- 用户 CRUD:账号、密码、姓名、角色、科室、电话、邮箱、启用状态。
- 头像设置;禁止停用当前登录账号等业务保护。
- 前端菜单与路由按角色隐藏/拦截;后端接口权限校验。
### 3.3 前端页面与路由对照
| 路由 | 页面 | 权限 |
|------|------|------|
| `/login` | 登录 | 公开 |
| `/dashboard` | 仪表盘 | 已登录 |
| `/patients` | 患者管理 | 已登录 |
| `/imaging` | 影像诊断 | 已登录 |
| `/emrs` | 电子病历 | 已登录 |
| `/appointments` | 预约挂号 | 已登录 |
| `/ai-assistant` | AI 助手 | 已登录 |
| `/ai-knowledge` | 知识库 | 已登录 |
| `/ai-settings` | AI 配置 | ADMIN |
| `/ai-yolo` | YOLO 权重 | ADMIN |
| `/users` | 用户管理 | ADMIN |
| `/403` | 无权访问 | 已登录 |
### 3.4 与早期版本的能力对比
| 维度 | 早期(PROJECT_REPORT 描述) | 当前实现 |
|------|------------------------------|----------|
| 前端 | Thymeleaf + Bootstrap | Vue 3 + Element Plus + ECharts |
| 安全 | HttpSession | Spring Security + JWT |
| AI 影像 | 规则/随机模拟 | FastAPI + YOLO 实检 + 报告(LLM 或模板) |
| 决策 | 关键字规则 | RAG + 可选 LLM,失败回退模板 |
| 业务广度 | 患者 / 影像 / 病历 | 增加预约、仪表盘增强、知识库、AI 配置、YOLO 管理 |
| 数据库 | 依赖 MySQL | 默认 H2,可选 MySQL profile |
---
## 四、技术栈
### 4.1 总体一览
| 层级 | 技术 | 版本(项目实际) | 用途 |
|------|------|------------------|------|
| 前端框架 | Vue | 3.5.10 | SPA |
| 构建工具 | Vite | 5.4.8 | 开发与打包 |
| UI | Element Plus | 2.8.4 | 组件库 |
| 状态 | Pinia | 2.2.4 | 用户会话等 |
| 路由 | Vue Router | 4.4.5 | 前端路由与守卫 |
| HTTP | Axios | 1.7.7 | 调用 `/api` |
| 图表 | ECharts + vue-echarts | 5.5.1 / 7.0.3 | 仪表盘 |
| 文档渲染 | marked + DOMPurify | 18.x / 3.x | AI 对话 Markdown |
| 业务后端 | Spring Boot | 3.3.4 | REST / 安全 / JPA |
| 语言 | Java | 17 | 后端 |
| 安全 | Spring Security + jjwt | 0.12.6 | JWT |
| ORM | Spring Data JPA / Hibernate | 随 Boot 3.3 | 持久化 |
| 数据库 | H2(默认)/ MySQL(可选) | — | 业务数据 |
| 工具 | Lombok | — | 实体简化 |
| 构建 | Maven(含 mvnw) | — | 后端构建 |
| AI 服务 | FastAPI + Uvicorn | ≥0.110 / ≥0.27 | AI 微服务 |
| 视觉 | Ultralytics YOLO + OpenCV | — | 检测与标注 |
| 预处理 | OpenCV / 可选 MONAI | — | 影像预处理 |
| LLM / RAG | LangChain 生态 + httpx | ≥0.2 | 报告、决策、检索 |
| 大模型 | DeepSeek 等 OpenAI 兼容 API | 可配置 | 文本生成 |
### 4.2 前端技术细节
- **工程**:`frontend/`,`type: module`,Vite 开发服务器默认 **5173**。
- **代理**:开发态将 `/api` 代理到 `http://localhost:8080`。
- **自动导入**:`unplugin-auto-import`、`unplugin-vue-components` 简化 Element Plus 使用。
- **布局**:`BasicLayout` 侧边栏 + 顶栏 + 主内容滚动;Dialog/Drawer 普遍 `append-to-body`,避免被 `overflow` 裁切。
- **生产构建**:`npm run build` 产物可置于 Nginx 或由 Spring 静态资源 / SPA 回退托管。
### 4.3 业务后端技术细节
- **工程**:`smart-hospital/`(Maven 子工程)。
- **端口**:`8080`。
- **包结构**:`controller` / `service` / `repository` / `model` / `dto` / `security` / `config` / `common`。
- **统一响应**:`Result<T>`,`code == 0` 表示成功;全局异常处理业务码与校验错误。
- **异步诊断**:`AsyncConfig` 线程池 + `AIDiagnosisService` 异步分析。
- **文件存储**:影像与头像本地目录 `./uploads/`;AI 设置与聊天 `./data/ai/`。
- **AI 客户端**:`AiServiceClient` 调用 FastAPI;失败时服务内降级逻辑保证可用性。
- **LLM 同步**:`AiLlmSyncRunner` / `AiSettingsService` 将管理端配置同步到 AI 微服务。
### 4.4 AI 微服务技术细节
- **工程**:`ai-service/`,Python 3.10+(开发环境实测 3.12 可用)。
- **端口**:`8001`。
- **主要路由模块**:
- `/health` — 健康与能力探测
- `/imaging/analyze` — YOLO 检测 + 报告
- `/report/imaging`、`/report/decision` — 报告与决策
- `/rag/*` — 知识检索与写入
- `/yolo/*` — 权重与统计
- `/llm-config` — 运行时 LLM 配置
- **报告策略**:
- 启用 LLM:结构化 JSON(所见 / 印象 / 建议),再由模板拼接分段 `full_report`
- 未启用或失败:模板 / 规则文案
- **内置知识**:`app/knowledge/` 下高血压、糖尿病、肺炎、肺结节、骨折等 Markdown 片段。
- **权重目录**:`data/weights/`(如 `best.pt`、`yolov8n.pt` 等演示权重)。
### 4.5 数据库与配置
| 项 | 默认(H2) | MySQL Profile |
|----|------------|---------------|
| 连接 | `jdbc:h2:mem:smart_hospital` | `application-mysql.yml` |
| 控制台 | `/h2-console`(sa / 空密码) | — |
| DDL | `hibernate.ddl-auto: update` | 同左或按环境调整 |
| 种子数据 | `DataInitializer` 自动写入 | 同左 |
其他关键配置(`application.yml`):
- `jwt.*`:密钥、过期时间、Header 前缀
- `cors.allowed-origins`:含 `http://localhost:5173`
- `ai.service.base-url`:`http://127.0.0.1:8001`
- `imaging.storage.path`:`./uploads/images`
- 上传限制:业务 multipart 最大约 500MB(兼容 YOLO 权重上传)
---
## 五、系统架构
### 5.1 逻辑架构
```
┌──────────────────────┐
│ 浏览器 Vue SPA │
│ localhost:5173 │
└──────────┬───────────┘
│ /api (Vite 代理)
▼
┌──────────────────────┐
│ Spring Boot 业务端 │
│ localhost:8080 │
│ JWT / JPA / 文件 │
└──────────┬───────────┘
│ HTTP(可选)
┌─────────────┴─────────────┐
▼ ▼
┌────────────────┐ ┌─────────────────┐
│ H2 / MySQL │ │ FastAPI AI 服务 │
│ 业务库 │ │ localhost:8001 │
└────────────────┘ │ YOLO / RAG / LLM│
└────────┬────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
本地权重 pt 知识 Markdown 外部 LLM API
```
### 5.2 调用关系原则
1. **浏览器只访问业务后端**(开发时经 Vite 代理),不直接依赖 AI 端口(管理端部分 YOLO 能力可经 Spring 转发)。
2. **AI 能力集中在 FastAPI**;Spring 负责鉴权、落库、任务状态与降级。
3. **配置单向同步**:管理端保存 LLM 配置 → 持久化 JSON → 同步 AI 运行时。
4. **失败可降级**:AI 超时或宕机时,诊断与决策仍可返回规则/模板结果。
### 5.3 部署形态(实训推荐)
| 进程 | 命令摘要 | 端口 |
|------|----------|------|
| AI | `uvicorn app.main:app --host 0.0.0.0 --port 8001` | 8001 |
| 业务 | `mvnw spring-boot:run` 或 `java -jar …jar` | 8080 |
| 前端 | `npm run dev` | 5173 |
生产可仅保留 AI + 业务 jar,前端 `build` 后由 Nginx 或 Spring 静态托管。
---
## 六、目录与模块结构
```
smart-hospital/ # 仓库根
├── README.md # 启动与接口速览
├── PROJECT_REPORT.md # 早期探索报告(历史参考)
├── 项目详细文档.md # 本文件
├── smart-hospital.sql # MySQL 初始化脚本(可选)
├── data/ai/ # 根目录侧 AI 文件(若存在)
├── uploads/ # 根目录侧上传样例(若存在)
│
├── frontend/ # Vue 3 前端
│ ├── package.json
│ ├── vite.config.js
│ └── src/
│ ├── api/ # 按域划分的 HTTP 封装
│ ├── layouts/ # BasicLayout
│ ├── router/ # 路由与守卫
│ ├── stores/ # Pinia(用户)
│ ├── utils/ # 标签映射、Markdown 等
│ └── views/ # 各业务页面
│
├── smart-hospital/ # Spring Boot 业务后端
│ ├── pom.xml
│ ├── mvnw / mvnw.cmd
│ ├── data/ai/ # settings.json、chat-history.json
│ ├── uploads/ # 影像、头像、标注图
│ └── src/main/
│ ├── java/com/hospital/ # 应用代码
│ └── resources/
│ ├── application.yml
│ ├── application-mysql.yml
│ └── static/ # 可选内嵌前端构建产物
│
└── ai-service/ # FastAPI AI 微服务
├── requirements.txt
├── README.md
├── data/weights/ # YOLO 权重
├── samples/ # 示例影像
└── app/
├── main.py
├── api/ # imaging / report / rag / yolo / llm_config
├── services/ # yolo、report、rag、llm
├── schemas/
└── knowledge/ # RAG 文档片段
```
---
## 七、核心业务流程
### 7.1 AI 影像诊断流程
```
医生/技师 新建影像记录(上传图片)
│
▼
状态 = PENDING
│
│ 点击「AI 诊断」
▼
状态 = ANALYZING ──异步──► Spring AIDiagnosisService
│ │
│ ▼
│ 调用 FastAPI /imaging/analyze
│ │
│ ┌─────────┴─────────┐
│ ▼ ▼
│ YOLO 检测框 报告生成
│ 绘制标注图 (LLM 或模板)
│ │ │
│ └─────────┬─────────┘
│ ▼
│ 写 AIDiagnosisResult
│ 更新 ImagingRecord
▼
状态 = COMPLETED / ERROR
│
▼
前端轮询 / 打开报告弹窗
(所见 · 印象 · 建议 · 检测明细 · 完整报告)
```
### 7.2 电子病历辅助决策流程
```
医生填写并保存病历(含诊断等字段)
│
▼
Spring DecisionSupportService
│
▼
FastAPI /report/decision
│
├─ RAG 检索 knowledge + 业务知识库
├─ 可选 LLM 生成结构化建议
└─ 失败则按诊断关键词走模板
│
▼
返回治疗 / 用药 / 护理 / 随访 / 风险 / 冲突 / 来源
│
▼
前端 Dialog 分节展示
```
### 7.3 LLM 配置同步
```
管理员在「AI 配置」保存
│
▼
写入 ./data/ai/settings.json
│
▼
调用 AI 服务 llm_config 接口
│
▼
FastAPI 运行时启用/更新 LLM
(报告与决策从模板切到大模型)
```
---
## 八、数据模型概要
### 8.1 主要业务实体
| 实体 | 表名 | 要点 |
|------|------|------|
| User | users | 角色 ADMIN/DOCTOR/RADIOLOGIST,BCrypt 密码,科室等 |
| Patient | patients | 姓名、性别、年龄、证件、联系方式、既往史 |
| ImagingRecord | imaging_records | 患者、医生、检查类型、部位、图像 URL、状态、AI 摘要字段 |
| AIDiagnosisResult | ai_diagnosis_results | 诊断文本、置信度、所见、建议、检测 JSON、标注图、完整报告、引擎与是否降级 |
| ElectronicMedicalRecord | electronic_medical_records | 主诉至随访全字段 + 关联患者/医生 |
| DecisionSupportRecord 等 | 决策相关表 | 辅助决策落库(按实现) |
| Appointment | appointments | 预约日、科室、事由、状态机 |
| AiKnowledgeDoc | 知识文档表 | 标题、分类、正文、启用 |
| AiSettings / 聊天 | 文件为主 | `settings.json`、`chat-history.json` |
### 8.2 影像状态机
| 状态 | 含义 |
|------|------|
| PENDING | 已登记,待诊断 |
| ANALYZING | 诊断进行中 |
| COMPLETED | 成功,可查看报告 |
| ERROR | 失败 |
### 8.3 检查类型
`X_RAY` · `CT` · `MRI` · `ULTRASOUND`
### 8.4 关系简图
```
User ──┬──< ImagingRecord >── Patient
│ │
│ └── AIDiagnosisResult
│
├──< ElectronicMedicalRecord >── Patient
│ │
│ └── Decision / Suggestions(按实现落库)
│
└──< Appointment >── Patient
```
---
## 九、接口与权限
### 9.1 统一响应
```json
{
"code": 0,
"message": "OK",
"data": {}
}
```
`code != 0` 时,前端 Axios 拦截器统一 `ElMessage` 提示。
### 9.2 业务 REST 一览(节选)
| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| POST | `/api/auth/login` | 登录 | 公开 |
| GET | `/api/auth/me` | 当前用户 | 已登录 |
| POST | `/api/auth/change-password` | 修改密码 | 已登录 |
| GET | `/api/stats/overview` | 统计概览 | 已登录 |
| * | `/api/patients/**` | 患者 CRUD / profile | 已登录 |
| * | `/api/imaging/**` | 影像 CRUD / 上传 | 已登录 |
| POST | `/api/ai-diagnosis/analyze/{id}` | 触发诊断 | 已登录 |
| GET | `/api/ai-diagnosis/result/{id}` | 诊断结果 | 已登录 |
| * | `/api/emrs/**` | 病历 CRUD | 已登录 |
| GET | `/api/emrs/{id}/ai-suggestions` | 辅助决策 | 已登录 |
| * | `/api/appointments/**` | 预约 CRUD / 状态 | 已登录 |
| * | `/api/users/**` | 用户管理 | ADMIN |
| * | AI 管理 / 聊天 / 知识库等 | 见对应 Controller | 已登录或 ADMIN |
### 9.3 AI 微服务接口(节选)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/health` | 健康检查 |
| POST | `/imaging/analyze` | 检测 + 报告 |
| POST | `/report/decision` | 病历决策 |
| POST | `/report/imaging` | 单独报告 |
| POST | `/rag/query` | 知识问答 |
| * | `/yolo/*` | 权重与统计 |
| * | `/llm-config` | 运行时 LLM 配置 |
完整 OpenAPI:`http://127.0.0.1:8001/docs`。
---
## 十、部署与运行
### 10.1 环境要求
| 组件 | 要求 |
|------|------|
| JDK | 17+ |
| Node.js | 18+ |
| Python | 3.10+(推荐 3.11/3.12) |
| 可选 | MySQL 8.x;NVIDIA GPU(非必须,CPU 可跑) |
### 10.2 启动顺序(推荐)
```text
1) ai-service :8001
2) smart-hospital :8080
3) frontend :5173
```
#### AI 服务
```bash
cd ai-service
python -m venv .venv
# Windows: .venv\Scripts\activate
pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
pip install -r requirements.txt
# 可选:配置 .env 中 LLM_API_KEY
uvicorn app.main:app --host 0.0.0.0 --port 8001
```
#### 业务后端
```bash
cd smart-hospital
mvnw.cmd spring-boot:run
# 或
java -jar target/smart-hospital-1.0.0.jar
```
MySQL 模式:
```bash
mvnw.cmd spring-boot:run -Dspring-boot.run.profiles=mysql
```
#### 前端
```bash
cd frontend
npm install
npm run dev
```
浏览器访问:`http://localhost:5173`。
### 10.3 健康检查
| 服务 | 地址 |
|------|------|
| 前端 | 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 |
---
## 十一、演示账号与推荐路径
### 11.1 内置账号(DataInitializer)
| 用户名 | 密码 | 角色 | 说明 |
|--------|------|------|------|
| admin | admin123 | ADMIN | 用户管理、AI 配置、YOLO 权重 |
| doctor1 | pass123 | DOCTOR | 临床业务主演示账号 |
| radio1 | radio123 | RADIOLOGIST | 影像相关演示 |
### 11.2 推荐演示剧本
1. 使用 `doctor1 / pass123` 登录,浏览仪表盘 KPI 与图表。
2. **患者管理**:查看列表 → 打开 360° 档案。
3. **影像诊断**:新建检查并上传样例图 → AI 诊断 → 对比原图/标注图 → 阅读分段完整报告。
4. **电子病历**:新建病历,诊断填写「高血压」或「肺炎」→ 查看 AI 建议与知识库引用。
5. **预约挂号**:新建预约并切换状态。
6. 切换 `admin`:进入 AI 配置(可填 DeepSeek Key)、YOLO 权重、用户管理。
7. 对比:关闭 AI 服务后再次诊断,观察 **降级** 是否仍返回结果。
### 11.3 验证清单(节选)
- [ ] 三端均能启动,5173 可登录
- [ ] admin 可见用户管理;doctor1 访问 `/users` 为 403
- [ ] 患者增删改与档案抽屉正常
- [ ] 影像 AI 状态能到 COMPLETED,报告分段清晰
- [ ] 病历 AI 建议弹窗含多类内容
- [ ] 预约状态可流转
- [ ] 修改密码后需重新登录
---
## 十二、设计说明与边界
### 12.1 关键设计取舍
| 取舍 | 原因 |
|------|------|
| 默认 H2 | 降低实训环境门槛,开箱即演示 |
| AI 独立进程 | 隔离 Python 视觉/LLM 依赖,避免撑爆 Java 工程 |
| JWT 无状态 | 适配前后端分离与多端调用 |
| LLM 可关 | 无 Key 时用模板/RAG,保证答辩可演示 |
| 报告强制分段拼接 | 避免大模型输出「墙文本」影响阅读 |
| Dialog append-to-body | Element Plus 2.8 默认不挂 body,易被布局 overflow 裁切 |
### 12.2 已知边界(非缺陷说明)
- 非完整 PACS/RIS/HIS 产品,无医保、收费、电子签名、CA 等模块。
- YOLO 类别与权重为演示级,不保证临床敏感性/特异性。
- H2 内存库重启丢失业务表数据;需持久化请改用 MySQL profile。
- 大模型与外部 API 受网络、额度、延迟影响;超时有配置上限。
- 早期 `PROJECT_REPORT.md` 描述的是改造前架构,**以本文档与当前代码为准**。
### 12.3 后续可扩展方向(建议)
- DICOM 解析与序列阅片
- 报告 PDF 导出与医师电子签收工作流
- 更细粒度的科室/数据权限与操作审计
- 向量库(如 Chroma/FAISS)替换简易 RAG
- Docker Compose 一键拉起三端
- 接口自动化测试与 CI
---
## 附录 A:技术栈版本速查表
| 名称 | 版本 |
|------|------|
| Spring Boot | 3.3.4 |
| Java | 17 |
| jjwt | 0.12.6 |
| Vue | 3.5.10 |
| Vite | 5.4.8 |
| Element Plus | 2.8.4 |
| Pinia | 2.2.4 |
| Vue Router | 4.4.5 |
| Axios | 1.7.7 |
| ECharts | 5.5.1 |
| FastAPI | ≥0.110 |
| Ultralytics | requirements 中指定 |
| LangChain | ≥0.2 |
## 附录 B:文档维护
| 项 | 说明 |
|----|------|
| 本文档路径 | `项目详细文档.md`(仓库根目录) |
| 快速启动 | 见 `README.md` |
| AI 专项 | 见 `ai-service/README.md` |
| 历史探索 | 见 `PROJECT_REPORT.md`(可能过时) |
---
**文档结束**