commit 9b8b8b57b395531c8af95fcb930fe80a3671b36a Author: shuai Date: Fri Jul 31 12:27:41 2026 +0800 26-7-31-1 diff --git a/.env b/.env new file mode 100644 index 0000000..e98c2a5 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +DEMO_MODE=auto + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79cfc7f --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Dependencies / build (可重建) +frontend/node_modules/ +frontend/dist/ +smart-hospital/target/ +ai-service/.venv/ +**/__pycache__/ +**/*.pyc +**/.pytest_cache/ +**/.mypy_cache/ + +# Runtime local data (可选保留,默认忽略大缓存) +# uploads/ # 演示影像,按需 +ai-service/data/yolo_stats.json + +# IDE / OS +.idea/ +.vscode/ +*.iml +.DS_Store +Thumbs.db +*.log +*.tmp diff --git a/PROJECT_REPORT.md b/PROJECT_REPORT.md new file mode 100644 index 0000000..a8fbddb --- /dev/null +++ b/PROJECT_REPORT.md @@ -0,0 +1,213 @@ +# 智慧医院管理系统 - 项目探索报告 + +> 生成日期:2026-07-22 +> 项目路径:`E:\桌面\实训项目\smart-hospital` + +--- + +## 1. 项目概述 + +一个基于 **Spring Boot** 的教学/实训级 Web 应用,融合 **AI 辅助诊断** 概念,覆盖医院核心业务链条: + +**登录 → 患者管理 → 影像检查 + AI 诊断 → 电子病历 + AI 辅助决策** + +规模轻量:源码约 **43 KB**,共 **23 个 Java 文件** + **5 个 Thymeleaf 页面**。 + +> ⚠️ AI 部分为**规则模拟实现**——通过关键字匹配和随机结果生成,并非真实模型调用,属于教学演示性质。 + +--- + +## 2. 技术栈 + +### 后端 +- **语言**:Java 17 +- **框架**:Spring Boot 2.7.0(Spring MVC、Spring Data JPA、`@EnableAsync`) +- **ORM**:Hibernate(JPA 注解),`ddl-auto: update` +- **数据库**:MySQL 8.0.33(`com.mysql.cj.jdbc.Driver`) +- **工具库**:Lombok(`@Data`)、Gson 2.9.0 +- **构建**:Maven(含 `mvnw` 包装脚本) +- **并发**:`ExecutorService` 固定线程池(5 线程)+ `CompletableFuture` + +### 前端 +- **模板引擎**:Thymeleaf(服务端渲染) +- **UI 框架**:Bootstrap 5.3.0(CDN 引入) +- **交互**:原生 JavaScript `fetch` 调用 REST 接口,轮询获取诊断结果 + +### 未使用 +Spring Security、Redis、Swagger、MyBatis、Vue/React + +--- + +## 3. 项目结构 + +``` +smart-hospital/ +├── smart——hospital.sql # 数据库初始化脚本 +└── smart-hospital/ # Maven 项目根 + ├── pom.xml + ├── mvnw, mvnw.cmd + ├── src/main/java/com/hospital/ + │ ├── SmartHospitalApplication.java # 启动类 + │ ├── controller/ # 3 个 Controller + │ ├── service/ # 2 个 Service + │ ├── model/ # 11 个实体/DTO + │ └── repository/ # 6 个 JPA Repository + ├── src/main/resources/ + │ ├── application.yml + │ └── templates/ # 5 个 Thymeleaf 页面 + └── src/test/java/com/hospital/ + └── SmartHospitalApplicationTests.java +``` + +--- + +## 4. 核心模块 / 功能 + +### 页面模块(Thymeleaf) +| 路径 | 功能 | +|---|---| +| `/login` | 登录/登出(测试账号 `doctor1 / pass123`) | +| `/dashboard` | 仪表盘(患者/影像/病历计数) | +| `/patients` | 患者列表(分页) | +| `/imaging` | 影像检查记录 + AI 诊断触发 + 结果轮询 | +| `/emrs` | 电子病历列表 | + +### 业务模块 +- **用户与权限**:三种角色 `DOCTOR / RADIOLOGIST / ADMIN`,权限仅通过 `HttpSession` 判断登录状态 +- **患者管理**:CRUD 骨架(当前仅列表查询) +- **影像诊断(AI)**:异步任务 `PENDING → ANALYZING → COMPLETED / ERROR`,返回诊断文本 + 置信度(85%-99%) +- **电子病历 + 决策支持**:新建 EMR 时联动生成治疗方案、用药建议、风险评估、药物冲突提示 + +--- + +## 5. 数据库设计(6 张表) + +数据库 `smart_hospital`(utf8mb4): + +| 表名 | 用途 | 关键字段 | +|---|---|---| +| `users` | 医护用户 | `role`(DOCTOR/RADIOLOGIST/ADMIN),`username` 唯一 | +| `patients` | 患者 | `gender`、`id_card` 唯一 | +| `imaging_records` | 影像检查记录 | `study_type`(X_RAY/CT/MRI/ULTRASOUND)、`status`、`ai_diagnosis`、`ai_confidence` | +| `ai_diagnosis_results` | AI 诊断结果 | `diagnosis_text`、`confidence_score`、`findings`、`recommendations`、`model_version` | +| `electronic_medical_records` | 电子病历 | 主诉/现病史/查体/诊断/治疗方案/用药/随访/AI 建议 | +| `decision_support_records` | 辅助决策记录 | `query_text`、`ai_response`、`doctor_feedback` | + +### 关系简图 +``` +users ─┐ + ├─→ imaging_records → ai_diagnosis_results +patients ─┘ + +users ─┐ + ├─→ electronic_medical_records → decision_support_records +patients ─┘ +``` + +--- + +## 6. API 接口 + +### REST 接口(`@RestController`,均带 `@CrossOrigin(origins = "*")`) +| 方法 | 路径 | 说明 | +|---|---|---| +| POST | `/api/ai-diagnosis/analyze/{recordId}` | 提交某条影像记录的 AI 诊断任务(异步) | +| GET | `/api/ai-diagnosis/result/{recordId}` | 查询 AI 诊断结果 | +| POST | `/api/emr/create` | 创建电子病历 + 返回 AI 辅助决策 | +| GET | `/api/emr/{emrId}/ai-suggestions` | 获取指定病历的 AI 决策建议 | + +### 页面路由 +`GET /login`、`POST /login`、`GET /logout`、`GET /dashboard`、`GET /patients`、`GET /imaging`、`GET /emrs`(均支持 `page`、`size` 分页参数) + +--- + +## 7. 配置说明 + +**关键配置**(`application.yml`): +- MySQL:`jdbc:mysql://localhost:3306/smart_hospital`,`root / 123456` +- JPA:`ddl-auto: update`,`show-sql: false` +- Server 端口:`8080` +- 自定义参数(**目前代码中未被读取**): + - `ai.diagnosis.enabled`、`max-concurrent-tasks: 5`、`timeout-seconds: 300`、`confidence-threshold: 0.85` + - `imaging.storage.path: ./uploads/images`、`max-file-size: 50MB` + +--- + +## 8. 构建与运行 + +**先决条件**:JDK 17、MySQL 8.x(默认 `root / 123456`) + +**步骤**: +1. 执行 `smart——hospital.sql` 初始化数据库 +2. 在项目根下运行: + ```powershell + mvnw.cmd spring-boot:run + ``` + 或打包后运行: + ```powershell + mvnw.cmd package + java -jar target/smart-hospital-1.0.0.jar + ``` +3. 打开 `http://localhost:8080/login`,账号 `doctor1 / pass123` + +**缺失**:无 `README.md`、无 Docker / CI 配置。 + +--- + +## 9. 代码规模 + +| 分类 | 数量 | +|---|---| +| Java 源文件 | 23(Application 1、Controller 3、Service 2、Model 11、Repository 6、Test 1) | +| Thymeleaf 模板 | 5 | +| 配置文件 | `application.yml` + `pom.xml` | +| SQL 脚本 | 1(约 100 行) | +| **源码总量** | **约 43 KB** | + +--- + +## 10. 技术亮点与潜在问题 + +### ✅ 亮点 +- **分层清晰**:Controller / Service / Repository / Model 四层结构标准 +- **异步处理**:AI 诊断走 `@EnableAsync` + `CompletableFuture` + 独立线程池 +- **前端轮询**:`imaging.html` 用定时 `fetch` 轮询任务状态,交互思路完整 +- **数据库设计合理**:外键、枚举、时间戳完善 +- **Lombok** 简化了实体样板代码 + +### ⚠️ 潜在问题 +| 问题 | 说明 | +|---|---| +| **AI 是伪实现** | 关键字 + `Math.random()` 生成结果;`model_version` 硬编码 `"AI-DIAG-V2.1"` | +| **安全性差** | 密码明文存储;未使用 Spring Security;`@CrossOrigin(*)` 全开 | +| **权限缺失** | 角色定义了,但业务代码不区分角色 | +| **登录效率低** | `userRepository.findAll().stream()` 全表遍历比对用户名 | +| **配置未落地** | `ai.diagnosis.*`、`imaging.storage.*` 无 `@Value` / `@ConfigurationProperties` 读取 | +| **线程池未托管** | 手动 `new`,未在应用关闭时优雅停止 | +| **缺少全局异常处理** | Controller 直接 `throw RuntimeException` | +| **业务功能不完整** | 只有列表查询页面,缺新增/编辑/删除、影像上传等 UI | +| **测试极简** | 仅一个空的 `contextLoads()` | +| **文件命名瑕疵** | SQL 文件名用了**中文破折号** `——`,跨平台易出错 | +| **`target/` 已提交** | 编译产物被跟踪进版本控制 | + +--- + +## 11. 关键文件路径速查 + +| 文件 | 路径 | +|---|---| +| 启动类 | `smart-hospital\src\main\java\com\hospital\SmartHospitalApplication.java` | +| 配置 | `smart-hospital\src\main\resources\application.yml` | +| 构建 | `smart-hospital\pom.xml` | +| 数据库脚本 | `smart——hospital.sql` | +| AI 诊断服务 | `smart-hospital\src\main\java\com\hospital\service\AIDiagnosisService.java` | +| 决策支持服务 | `smart-hospital\src\main\java\com\hospital\service\DecisionSupportService.java` | +| 页面控制器 | `smart-hospital\src\main\java\com\hospital\controller\PageController.java` | + +--- + +## 12. 总体评价 + +一个**结构规范、覆盖完整业务链条**的教学演示项目。核心技术栈标准(Spring Boot + JPA + Thymeleaf + MySQL),业务模型设计得体,但在**安全、权限、真实 AI 能力、异常处理和 CRUD 完整度**方面都存在明显扩展空间。 + +**非常适合作为二次开发或课程改造的起点。** diff --git a/README.md b/README.md new file mode 100644 index 0000000..18b36fb --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# 智慧医院 AI 影像诊断与电子病历辅助决策系统 + +混合架构:**Spring Boot 3**(业务 / JWT)+ **FastAPI AI 微服务**(YOLO / MONAI / LangChain RAG / DeepSeek)+ **Vue 3**(Element Plus + ECharts)。 + +## 目录结构 + +``` +smart-hospital/ +├── smart-hospital/ # 业务后端 Maven 项目(Spring Boot 3.3) +├── ai-service/ # AI 微服务(FastAPI + YOLO + LangChain RAG) +├── frontend/ # 前端 Vue 3 项目 +├── smart-hospital.sql # 数据库初始化脚本 +└── PROJECT_REPORT.md # 早期探索汇报 +``` + +## 〇、AI 微服务启动(推荐先启) + +先决条件:Python 3.10+。 + +```bash +cd ai-service +python -m venv .venv +.venv\Scripts\activate # Windows +# source .venv/bin/activate # macOS/Linux +# 建议先装 CPU 版 torch(避免 CUDA 包体积过大) +pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu +pip install -r requirements.txt +copy .env.example .env # 可填 DeepSeek/Qwen 的 LLM_API_KEY +uvicorn app.main:app --host 0.0.0.0 --port 8001 +``` + +> 说明:`.venv`、`frontend/node_modules`、`smart-hospital/target` 为可重建依赖,不入库; +> 删掉后按上列命令重新安装即可。源码 + 医学权重精简后约数十 MB。 + +- 健康检查:http://127.0.0.1:8001/health +- Swagger:http://127.0.0.1:8001/docs +- 未启动 AI 服务时,Spring Boot 会**自动降级**为本地规则模拟,业务仍可用。 +- 详见 `ai-service/README.md`。 + +## 一、业务后端启动 + +先决条件:JDK 17+(默认 **H2 内存库**,无需安装 MySQL)。 + +1. 启动(默认 H2): + ```bash + cd smart-hospital + ./mvnw spring-boot:run # macOS/Linux + mvnw.cmd spring-boot:run # Windows + ``` +2. 可选 H2 控制台:`http://localhost:8080/h2-console` + JDBC URL:`jdbc:h2:mem:smart_hospital`,用户 `sa`,密码留空。 +3. 若要用本地 MySQL: + ```bash + # 先建库 smart_hospital,改 application-mysql.yml 账号密码后: + mvnw.cmd spring-boot:run -Dspring-boot.run.profiles=mysql + ``` +4. 启动后 `DataInitializer` 会自动写入测试账号: + - `admin / admin123`(角色 ADMIN,可管理用户) + - `doctor1 / pass123`(角色 DOCTOR) + - `radio1 / radio123`(角色 RADIOLOGIST) + +后端监听 `http://localhost:8080`,业务前缀 `/api/**`。 +AI 服务地址配置:`ai.service.base-url`(默认 `http://127.0.0.1:8001`)。 +> H2 为内存库,进程退出后数据清空;每次启动会重新初始化演示数据。 + +## 二、前端启动 + +先决条件:Node.js 18+。 + +```bash +cd frontend +npm install +npm run dev +``` + +打开 `http://localhost:5173`,Vite 会把 `/api` 代理到 `http://localhost:8080`。 + +生产构建: +```bash +npm run build +``` +产物在 `frontend/dist/`,可交给 Nginx 或后端静态资源目录托管。 + +## 三、主要功能 + +- 登录(JWT,token 存 localStorage,24h 过期)+ **修改密码** +- 仪表盘:患者/影像/病历/预约等多项 KPI + 近 7 天趋势 + 检查类型/诊断状态饼图 + 快捷入口与待办 +- 患者管理:分页 + 关键字搜索 + 增删改 + **360° 档案**(关联影像 / 病历 / 预约) +- 影像管理:CRUD + 上传 + **AI 诊断**(FastAPI YOLO 检测框/标注图 + 标准报告,不可用时本地降级) +- 电子病历:CRUD + **AI 辅助决策**(治疗/护理/随访 + LangChain RAG 知识库引用) +- AI 对话与知识库管理(管理端可配置 DeepSeek 等 OpenAI 兼容接口) +- **预约挂号**:新建/编辑/删 + 状态流转(预约→确认→完成/取消/未到诊)+ 按日期筛选 +- 用户管理:仅 ADMIN 角色可访问;前端路由守卫 + 后端 `@PreAuthorize` 双重校验 + +### 推荐演示路径 + +1. 依次启动:`ai-service:8001` → `smart-hospital:8080` → `frontend:5173` +2. 使用 `doctor1 / pass123` 登录 +3. **影像诊断**:上传图片 → AI 诊断 → 查看标注图与报告 +4. **电子病历**:诊断填写「高血压/肺炎」等 → 查看护理/治疗/随访与 RAG 来源 + +## 四、REST 接口一览 + +| 方法 | 路径 | 说明 | 权限 | +|---|---|---|---| +| POST | `/api/auth/login` | 登录,返回 `{token, user}` | 公开 | +| GET | `/api/auth/me` | 当前用户信息 | 已登录 | +| POST | `/api/auth/change-password` | 修改密码 | 已登录 | +| GET | `/api/stats/overview` | 统计概览(KPI + 趋势 + 分布) | 已登录 | +| GET/POST/PUT/DELETE | `/api/patients` 及 `/api/patients/{id}` | 患者 CRUD | 已登录 | +| GET | `/api/patients/{id}/profile` | 患者 360° 档案 | 已登录 | +| GET/POST/PUT/DELETE | `/api/imaging` 及 `/api/imaging/{id}` | 影像 CRUD(支持 keyword/status/studyType) | 已登录 | +| POST | `/api/imaging/upload` | 影像文件上传 | 已登录 | +| POST | `/api/ai-diagnosis/analyze/{recordId}` | 触发 AI 诊断 | 已登录 | +| GET | `/api/ai-diagnosis/result/{recordId}` | 查询 AI 诊断结果 | 已登录 | +| GET/POST/PUT/DELETE | `/api/emrs` 及 `/api/emrs/{id}` | 病历 CRUD(支持 keyword) | 已登录 | +| GET | `/api/emrs/{id}/ai-suggestions` | 获取 AI 辅助决策 | 已登录 | +| GET/POST/PUT/DELETE | `/api/appointments` 及 `/api/appointments/{id}` | 预约挂号 CRUD | 已登录 | +| PATCH | `/api/appointments/{id}/status` | 更新预约状态 | 已登录 | +| GET/POST/PUT/DELETE | `/api/users` 及 `/api/users/{id}` | 用户管理 | ADMIN | + +所有响应格式: +```json +{ "code": 0, "message": "OK", "data": { ... } } +``` +`code != 0` 由前端 axios 拦截器统一弹 `ElMessage`。 + +## 五、关键改造点 + +相较于原始 Thymeleaf 单体版本: + +- Spring Boot 从 2.7 升级到 3.3.4,`javax.*` → `jakarta.*`,MySQL 驱动坐标改为 `com.mysql:mysql-connector-j` +- 引入 Spring Security + JWT(`jjwt` 0.12),无状态会话;密码 BCrypt 存储 +- 引入统一 `Result` + `GlobalExceptionHandler`,Controller 不再直接抛 `RuntimeException` +- `AsyncConfig` 声明 `ThreadPoolTaskExecutor` Bean,替换 `AIDiagnosisService` 里手动 `Executors.newFixedThreadPool(5)` +- `AiProperties` / `StorageProperties` 落地 `application.yml` 里原本"悬空"的配置 +- `UserRepository.findByUsername` / `existsByUsername` 替换原 `findAll().stream()` 全表登录 +- 删除 `templates/` 与 `PageController`,Thymeleaf 服务端渲染完全被 Vue SPA 替换 +- 前端影像轮询增加 30 次上限 + `onBeforeUnmount` 清理,修复原页面死循环风险 + +## 六、验证清单 + +1. 后端 `mvnw package` 通过(当前工程已验证 BUILD SUCCESS) +2. 前端 `npm run dev` 启动成功后访问 5173 +3. 用 `admin/admin123` 登录 → 能看到「用户管理」菜单 +4. 用 `doctor1/pass123` 登录 → 访问 `/users` 显示 403 +5. 患者页新增-编辑-删除全流程可用;点「档案」可查看 360° 关联数据 +6. 影像页上传图片 → 新建记录 → 点「AI 诊断」→ 状态从 PENDING → ANALYZING → COMPLETED;筛选状态/类型可用 +7. 病历页新建后弹出 AI 辅助决策 dialog,显示治疗/用药/风险/冲突四类内容 +8. 预约挂号页可新建预约并切换状态(确认/完成/取消) +9. 右上角用户菜单 → 修改密码 → 成功后需重新登录 diff --git a/ai-service/.env.example b/ai-service/.env.example new file mode 100644 index 0000000..79dfcb5 --- /dev/null +++ b/ai-service/.env.example @@ -0,0 +1,17 @@ +# AI 服务端口 +AI_HOST=0.0.0.0 +AI_PORT=8001 + +# demo | real | auto(auto:有权重用真实 YOLO,否则演示检测) +DEMO_MODE=auto +YOLO_WEIGHTS= + +# DeepSeek / Qwen 等 OpenAI 兼容接口 +LLM_BASE_URL=https://api.deepseek.com +LLM_API_KEY= +LLM_MODEL=deepseek-chat +LLM_TEMPERATURE=0.3 + +# 知识库与向量缓存目录 +KNOWLEDGE_DIR=./app/knowledge +VECTOR_DIR=./data/vectorstore diff --git a/ai-service/.gitignore b/ai-service/.gitignore new file mode 100644 index 0000000..4ac4db2 --- /dev/null +++ b/ai-service/.gitignore @@ -0,0 +1,11 @@ +.venv/ +__pycache__/ +*.py[cod] +.env +data/ +*.jpg +*.jpeg +*.png +!samples/.gitkeep +.idea/ +.vscode/ diff --git a/ai-service/README.md b/ai-service/README.md new file mode 100644 index 0000000..b558329 --- /dev/null +++ b/ai-service/README.md @@ -0,0 +1,75 @@ +# Smart Hospital AI Service + +FastAPI 微服务:YOLO 影像检测(演示级)+ MONAI/OpenCV 预处理 + LangChain RAG + 报告/决策生成。 + +兼容 **DeepSeek / Qwen** 等 OpenAI 协议大模型。 + +## 快速启动 + +```bash +cd ai-service +python -m venv .venv + +# Windows +.venv\Scripts\activate +# macOS/Linux +# source .venv/bin/activate + +# CPU 版 torch(体积更小,实训推荐) +pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu +pip install -r requirements.txt +copy .env.example .env # 按需填写 LLM_API_KEY + +uvicorn app.main:app --host 0.0.0.0 --port 8001 +``` + +> `.venv` 可随时删除后按上面步骤重建;权重见 `data/weights/`(已精简保留医学检测模型)。 + +- 文档:http://127.0.0.1:8001/docs +- 健康检查:http://127.0.0.1:8001/health + +## 主要接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/health` | 服务与能力探测 | +| POST | `/imaging/analyze` | 影像 YOLO 检测 + 初步诊断/报告 | +| POST | `/report/decision` | EMR 辅助决策(护理/治疗/随访 + RAG) | +| POST | `/report/imaging` | 单独生成影像报告 | +| POST | `/rag/query` | 知识库问答 | +| POST | `/rag/ingest` | 追加知识文档 | + +## 环境变量(`.env`) + +| 变量 | 说明 | 默认 | +|------|------|------| +| `DEMO_MODE` | `demo` / `real` / `auto` | `auto` | +| `YOLO_WEIGHTS` | YOLO 权重路径 | 空则演示检测 | +| `LLM_BASE_URL` | OpenAI 兼容地址 | `https://api.deepseek.com` | +| `LLM_API_KEY` | API Key | 空则模板/RAG 摘要 | +| `LLM_MODEL` | 模型名 | `deepseek-chat` | + +## 可选视觉栈 + +```bash +pip install torch torchvision ultralytics monai +``` + +安装后将 `YOLO_WEIGHTS` 指向权重文件,并设 `DEMO_MODE=auto` 或 `real`。 + +## 与 Spring Boot 对接 + +业务后端配置(`application.yml`): + +```yaml +ai: + service: + enabled: true + base-url: http://127.0.0.1:8001 +``` + +前端仍只访问 Spring Boot `:8080`,由后端转发到本服务。 + +## 声明 + +输出仅供教学实训与辅助决策演示,**不能替代执业医师诊断**。 diff --git a/ai-service/app/__init__.py b/ai-service/app/__init__.py new file mode 100644 index 0000000..a84e810 --- /dev/null +++ b/ai-service/app/__init__.py @@ -0,0 +1 @@ +"""Smart Hospital AI Service.""" diff --git a/ai-service/app/api/__init__.py b/ai-service/app/api/__init__.py new file mode 100644 index 0000000..f7ec5ce --- /dev/null +++ b/ai-service/app/api/__init__.py @@ -0,0 +1 @@ +"""API routers.""" diff --git a/ai-service/app/api/health.py b/ai-service/app/api/health.py new file mode 100644 index 0000000..4f7e453 --- /dev/null +++ b/ai-service/app/api/health.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter + +from app.config import get_settings +from app.schemas.models import HealthResponse +from app.services.llm_client import get_llm +from app.services.monai_preprocess import monai_available +from app.services.rag_pipeline import get_rag, langchain_available +from app.services.yolo_detector import yolo_available + +router = APIRouter(tags=["health"]) + + +@router.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + settings = get_settings() + rag = get_rag() + llm = get_llm() + return HealthResponse( + status="ok", + yolo_available=yolo_available(), + monai_available=monai_available(), + langchain_available=langchain_available(), + llm_configured=llm.enabled, + demo_mode=settings.demo_mode, + knowledge_docs=rag.doc_count(), + ) diff --git a/ai-service/app/api/imaging.py b/ai-service/app/api/imaging.py new file mode 100644 index 0000000..db6d7e2 --- /dev/null +++ b/ai-service/app/api/imaging.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +from fastapi import APIRouter, File, Form, HTTPException, UploadFile + +from app.schemas.models import ImagingAnalyzeResponse +from app.services.monai_preprocess import load_image_bgr, preprocess +from app.services.report_generator import build_imaging_texts, generate_imaging_report, make_full_report +from app.schemas.models import ImagingReportRequest +from app.services.yolo_detector import get_detector +from app.services.yolo_manager import get_yolo_manager + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/imaging", tags=["imaging"]) + + +@router.post("/analyze", response_model=ImagingAnalyzeResponse) +async def analyze_imaging( + file: UploadFile | None = File(default=None), + image_path: str | None = Form(default=None), + study_type: str = Form(default="CT"), + body_part: str = Form(default=""), + patient_summary: str = Form(default=""), +) -> ImagingAnalyzeResponse: + raw = await _read_bytes(file, image_path) + try: + image_bgr = load_image_bgr(raw) + prep = preprocess(image_bgr) + detector = get_detector() + detections = detector.detect(image_bgr, study_type=study_type) + annotated = detector.annotate(image_bgr, detections) + findings, diagnosis, recommendations, confidence = build_imaging_texts( + study_type, body_part, detections, detector.mode + ) + report = generate_imaging_report( + ImagingReportRequest( + study_type=study_type, + body_part=body_part, + patient_summary=patient_summary, + preliminary_diagnosis=diagnosis, + findings=findings, + detections=detections, + confidence=confidence, + ) + ) + backend = prep.get("backend", "opencv") + model_version = f"yolo-{detector.mode}+{backend}+{report.model_version}" + try: + get_yolo_manager().record_inference( + mode=detector.mode, + detections=detections, + study_type=study_type, + model_version=model_version, + ) + except Exception as e: + logger.warning("记录 YOLO 统计失败: %s", e) + return ImagingAnalyzeResponse( + detections=detections, + annotated_image_base64=annotated, + preliminary_diagnosis=report.impression or diagnosis, + findings=report.findings or findings, + recommendations=report.recommendations or recommendations, + confidence=confidence, + model_version=model_version, + mode=detector.mode, # type: ignore[arg-type] + full_report=report.full_report + or make_full_report(study_type, body_part, findings, diagnosis, recommendations, patient_summary), + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + logger.exception("影像分析失败") + raise HTTPException(status_code=500, detail=f"影像分析失败: {e}") from e + + +async def _read_bytes(file: UploadFile | None, image_path: str | None) -> bytes: + if file is not None: + data = await file.read() + if not data: + raise HTTPException(status_code=400, detail="上传文件为空") + return data + if image_path: + path = Path(image_path) + if not path.is_file(): + raise HTTPException(status_code=400, detail=f"影像路径不存在: {image_path}") + return path.read_bytes() + raise HTTPException(status_code=400, detail="请提供 file 或 image_path") diff --git a/ai-service/app/api/llm_config.py b/ai-service/app/api/llm_config.py new file mode 100644 index 0000000..9fb125d --- /dev/null +++ b/ai-service/app/api/llm_config.py @@ -0,0 +1,70 @@ +"""管理端下发的 LLM 运行时配置。""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter +from pydantic import BaseModel, Field + +from app.config import get_settings +from app.services.llm_client import get_llm +from app.services.llm_runtime import get_runtime_llm, update_runtime_llm + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/llm", tags=["llm"]) + + +class LlmConfigUpdate(BaseModel): + enabled: bool | None = True + api_base_url: str | None = Field(default=None, alias="api_base_url") + api_key: str | None = None + model: str | None = None + temperature: float | None = None + + # 兼容 camelCase(Spring 默认可能发 apiBaseUrl) + apiBaseUrl: str | None = None + apiKey: str | None = None + + class Config: + populate_by_name = True + + +@router.get("/config") +def get_llm_config() -> dict: + return get_llm().info() + + +@router.post("/config") +def set_llm_config(body: LlmConfigUpdate) -> dict: + base = body.api_base_url or body.apiBaseUrl + key = body.api_key if body.api_key is not None else body.apiKey + # 注意:key 为 None 表示本次不改密钥;空字符串表示清空运行时密钥 + rt = update_runtime_llm( + enabled=body.enabled, + api_base_url=base, + api_key=key, + model=body.model, + temperature=body.temperature, + ) + info = get_llm().info() + logger.info( + "已更新运行时 LLM 配置 enabled=%s model=%s base=%s key=%s source=%s", + info.get("enabled"), + info.get("model"), + info.get("api_base_url"), + "yes" if info.get("api_key_configured") else "no", + rt.source, + ) + return {"ok": True, **info} + + +@router.get("/status") +def llm_status() -> dict: + settings = get_settings() + info = get_llm().info() + rt = get_runtime_llm() + return { + **info, + "env_key_configured": bool(settings.llm_api_key and settings.llm_api_key.strip()), + "runtime_enabled_flag": rt.enabled, + } diff --git a/ai-service/app/api/rag.py b/ai-service/app/api/rag.py new file mode 100644 index 0000000..1e547de --- /dev/null +++ b/ai-service/app/api/rag.py @@ -0,0 +1,49 @@ +from fastapi import APIRouter +from pydantic import BaseModel, Field + +from app.schemas.models import RagQueryRequest, RagQueryResponse, SourceRef +from app.services.rag_pipeline import get_rag + +router = APIRouter(prefix="/rag", tags=["rag"]) + + +class IngestRequest(BaseModel): + title: str + content: str + category: str = "自定义" + + +class IngestResponse(BaseModel): + ok: bool = True + chunks: int = 0 + + +@router.post("/query", response_model=RagQueryResponse) +def rag_query(body: RagQueryRequest) -> RagQueryResponse: + rag = get_rag() + result = rag.query(body.query, top_k=body.top_k, extra_context=body.context or "") + raw_sources = result.get("sources") or [] + sources: list[SourceRef] = [] + for s in raw_sources: + if isinstance(s, SourceRef): + sources.append(s) + elif isinstance(s, dict): + sources.append(SourceRef(**s)) + return RagQueryResponse( + answer=result.get("answer") or "", + sources=sources, + engine=result.get("engine") or "langchain-rag", + ) + + +@router.post("/ingest", response_model=IngestResponse) +def rag_ingest(body: IngestRequest) -> IngestResponse: + rag = get_rag() + rag.ingest_text(body.title, body.content, body.category) + return IngestResponse(ok=True, chunks=rag.doc_count()) + + +@router.get("/stats") +def rag_stats() -> dict: + rag = get_rag() + return {"chunks": rag.doc_count()} diff --git a/ai-service/app/api/report.py b/ai-service/app/api/report.py new file mode 100644 index 0000000..1a1fd0c --- /dev/null +++ b/ai-service/app/api/report.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter + +from app.schemas.models import ( + DecisionRequest, + DecisionResponse, + ImagingReportRequest, + ImagingReportResponse, +) +from app.services.report_generator import generate_decision, generate_imaging_report + +router = APIRouter(prefix="/report", tags=["report"]) + + +@router.post("/imaging", response_model=ImagingReportResponse) +def report_imaging(body: ImagingReportRequest) -> ImagingReportResponse: + return generate_imaging_report(body) + + +@router.post("/decision", response_model=DecisionResponse) +def report_decision(body: DecisionRequest) -> DecisionResponse: + return generate_decision(body) diff --git a/ai-service/app/api/yolo.py b/ai-service/app/api/yolo.py new file mode 100644 index 0000000..f7ceb29 --- /dev/null +++ b/ai-service/app/api/yolo.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import logging + +from fastapi import APIRouter, File, HTTPException, UploadFile +from pydantic import BaseModel, Field + +from app.services.yolo_detector import get_detector, reload_detector, yolo_available +from app.services.yolo_manager import get_yolo_manager + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/yolo", tags=["yolo"]) + + +class ActivateRequest(BaseModel): + name: str = Field(..., description="权重文件名") + demo_mode: str | None = Field(default=None, description="demo|real|auto") + + +class DemoModeRequest(BaseModel): + demo_mode: str = Field(..., description="demo|real|auto") + + +@router.get("/status") +def yolo_status() -> dict: + mgr = get_yolo_manager() + det = get_detector() + return mgr.status(det.info()) + + +@router.get("/weights") +def list_weights() -> dict: + mgr = get_yolo_manager() + return {"items": mgr.list_weights(), "count": len(mgr.list_weights())} + + +@router.post("/weights/upload") +async def upload_weight(file: UploadFile = File(...)) -> dict: + if not file.filename: + raise HTTPException(400, "缺少文件名") + raw = await file.read() + try: + item = get_yolo_manager().save_upload(file.filename, raw) + return {"ok": True, "weight": item} + except ValueError as e: + raise HTTPException(400, str(e)) from e + except Exception as e: + logger.exception("上传权重失败") + raise HTTPException(500, f"上传失败: {e}") from e + + +@router.post("/weights/activate") +def activate_weight(body: ActivateRequest) -> dict: + try: + status = get_yolo_manager().activate(body.name, body.demo_mode) + info = reload_detector() + status.update(info) + status["ok"] = True + return status + except FileNotFoundError as e: + raise HTTPException(404, str(e)) from e + except ValueError as e: + raise HTTPException(400, str(e)) from e + + +@router.post("/weights/deactivate") +def deactivate_weight() -> dict: + status = get_yolo_manager().deactivate() + info = reload_detector() + status.update(info) + status["ok"] = True + return status + + +@router.delete("/weights/{name}") +def delete_weight(name: str) -> dict: + """逻辑删除:隐藏权重,本地 .pt 文件仍保留在 data/weights。""" + try: + get_yolo_manager().delete_weight(name) + reload_detector() + return { + "ok": True, + "name": name, + "soft_delete": True, + "message": "已逻辑删除(本地文件保留,仅从列表隐藏)", + } + except FileNotFoundError as e: + raise HTTPException(404, str(e)) from e + except ValueError as e: + raise HTTPException(400, str(e)) from e + + +@router.post("/weights/restore") +def restore_weight(body: ActivateRequest) -> dict: + """恢复逻辑删除的权重。""" + try: + status = get_yolo_manager().restore_weight(body.name) + info = reload_detector() + status.update(info) + status["ok"] = True + return status + except FileNotFoundError as e: + raise HTTPException(404, str(e)) from e + + +@router.post("/mode") +def set_mode(body: DemoModeRequest) -> dict: + try: + status = get_yolo_manager().set_demo_mode(body.demo_mode) + info = reload_detector() + status.update(info) + status["ok"] = True + return status + except ValueError as e: + raise HTTPException(400, str(e)) from e + + +@router.get("/visualization") +def visualization() -> dict: + return get_yolo_manager().visualization() + + +@router.post("/stats/reset") +def reset_stats() -> dict: + get_yolo_manager().reset_stats() + return {"ok": True} + + +@router.get("/capability") +def capability() -> dict: + return { + "ultralytics": yolo_available(), + "detector": get_detector().info(), + "manager": get_yolo_manager().status(), + } diff --git a/ai-service/app/config.py b/ai-service/app/config.py new file mode 100644 index 0000000..5b26b1e --- /dev/null +++ b/ai-service/app/config.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path +from typing import Literal + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + ai_host: str = "0.0.0.0" + ai_port: int = 8001 + + demo_mode: Literal["demo", "real", "auto"] = "auto" + yolo_weights: str = "" + + llm_base_url: str = "https://api.deepseek.com" + llm_api_key: str = "" + llm_model: str = "deepseek-chat" + llm_temperature: float = 0.3 + + knowledge_dir: str = "./app/knowledge" + vector_dir: str = "./data/vectorstore" + + @property + def root_dir(self) -> Path: + return Path(__file__).resolve().parent.parent + + def resolve_path(self, value: str) -> Path: + p = Path(value) + if p.is_absolute(): + return p + return (self.root_dir / p).resolve() + + @property + def knowledge_path(self) -> Path: + return self.resolve_path(self.knowledge_dir) + + @property + def vector_path(self) -> Path: + return self.resolve_path(self.vector_dir) + + @property + def yolo_weights_path(self) -> Path | None: + if not self.yolo_weights or not self.yolo_weights.strip(): + return None + path = self.resolve_path(self.yolo_weights.strip()) + return path if path.is_file() else None + + @property + def llm_enabled(self) -> bool: + return bool(self.llm_api_key and self.llm_api_key.strip()) + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/ai-service/app/knowledge/cholecystolithiasis.md b/ai-service/app/knowledge/cholecystolithiasis.md new file mode 100644 index 0000000..719c64e --- /dev/null +++ b/ai-service/app/knowledge/cholecystolithiasis.md @@ -0,0 +1,19 @@ +# 胆囊结石辅助管理要点 + +category: 消化外科 + +## 表现 +右上腹痛、油腻饮食后加重,可伴恶心;超声是首选检查。 + +## 处理 +- 无症状结石可观察 +- 症状性结石评估腹腔镜胆囊切除指征 +- 合并胆管炎/胰腺炎需紧急处理 + +## 护理与饮食 +- 低脂饮食 +- 观察腹痛、黄疸、发热 +- 术后早期活动 + +## 注意 +实训演示用,非临床处方依据。 diff --git a/ai-service/app/knowledge/diabetes.md b/ai-service/app/knowledge/diabetes.md new file mode 100644 index 0000000..33555ba --- /dev/null +++ b/ai-service/app/knowledge/diabetes.md @@ -0,0 +1,24 @@ +# 2 型糖尿病辅助管理要点 + +category: 内分泌 + +## 诊断要点 +空腹血糖、OGTT 或 HbA1c 达到诊断阈值;需排除 1 型及其他特殊类型。 + +## 综合管理 +- 医学营养治疗与运动 +- 血糖自我监测 +- 个体化降糖药物(如二甲双胍等,注意禁忌) +- 血压、血脂与抗血小板综合干预 + +## 并发症筛查 +- 视网膜病变、肾病、神经病变、足病 +- 心血管风险评估 + +## 护理与随访 +- 低血糖识别与处理 +- 足部护理 +- 定期复查血糖与 HbA1c + +## 注意 +本资料用于教学演示,临床决策需结合指南与患者情况。 diff --git a/ai-service/app/knowledge/fracture.md b/ai-service/app/knowledge/fracture.md new file mode 100644 index 0000000..410a0c0 --- /dev/null +++ b/ai-service/app/knowledge/fracture.md @@ -0,0 +1,20 @@ +# 骨折影像与处置要点(示意) + +category: 骨科 + +## 影像 +- X 线是基础检查;复杂部位可 CT/MRI。 +- 描述骨折部位、类型、移位、关节受累。 + +## 处理原则 +- 复位、固定、功能锻炼 +- 开放伤注意清创抗感染 +- 评估血管神经损伤 + +## 护理与随访 +- 患肢肿胀、血运、感觉观察 +- 疼痛管理与防深静脉血栓 +- 按医嘱复查 X 光评估愈合 + +## 注意 +仅供实训演示,不作为临床操作依据。 diff --git a/ai-service/app/knowledge/general_emr.md b/ai-service/app/knowledge/general_emr.md new file mode 100644 index 0000000..0288c7d --- /dev/null +++ b/ai-service/app/knowledge/general_emr.md @@ -0,0 +1,17 @@ +# 电子病历与辅助决策书写规范(示意) + +category: 病历质控 + +## 病历要素 +主诉、现病史、体格检查、辅助检查、诊断、治疗计划、用药、随访。 + +## AI 辅助使用原则 +- AI 输出仅供参考,必须经医师审核修改后入档。 +- 引用知识库内容时注明来源类别。 +- 避免将模型幻觉内容写入正式病历。 + +## 护理建议常见结构 +病情观察、用药护理、生活指导、心理支持、健康宣教。 + +## 随访计划 +复诊时间、复查项目、危险症状返院指征。 diff --git a/ai-service/app/knowledge/hypertension.md b/ai-service/app/knowledge/hypertension.md new file mode 100644 index 0000000..b9b1e83 --- /dev/null +++ b/ai-service/app/knowledge/hypertension.md @@ -0,0 +1,24 @@ +# 原发性高血压辅助管理要点 + +category: 心血管 + +## 诊断 +非同日三次诊室血压 ≥140/90 mmHg 可诊断高血压;鼓励家庭血压与动态血压监测。 + +## 非药物治疗 +- 限盐(<5 g/日)、DASH 饮食 +- 规律有氧运动、控制体重 +- 戒烟限酒、减少精神压力 + +## 药物治疗(示意) +- 常用类别:ACEI/ARB、CCB、利尿剂、β 受体阻滞剂等。 +- 合并糖尿病、慢性肾病等需个体化选药。 +- 注意体位性低血压与电解质紊乱。 + +## 护理与随访 +- 规范测量并记录血压 +- 提高服药依从性宣教 +- 评估靶器官损害,定期复诊 + +## 注意 +用药方案必须由执业医师开具,本文仅供实训演示。 diff --git a/ai-service/app/knowledge/mri_spine.md b/ai-service/app/knowledge/mri_spine.md new file mode 100644 index 0000000..0da3834 --- /dev/null +++ b/ai-service/app/knowledge/mri_spine.md @@ -0,0 +1,22 @@ +# 颈椎/腰椎间盘突出 MRI 辅助解读要点 + +category: 骨科/影像 + +## 常见表现 +- 椎间盘信号改变、突出压迫硬膜囊或神经根 +- 可伴椎管狭窄、黄韧带肥厚 + +## 临床相关 +- 颈肩痛、上肢放射痛、腰痛、下肢放射痛、麻木 +- 需与脊髓病、肿瘤、感染鉴别 + +## 治疗路径(示意) +- 多数可先保守:休息、理疗、药物、功能锻炼 +- 神经功能障碍加重或马尾症状需紧急评估手术指征 + +## 随访 +- 症状变化记录 +- 必要时复查 MRI + +## 注意 +教学演示资料,诊断以影像科与临床医师为准。 diff --git a/ai-service/app/knowledge/pneumonia.md b/ai-service/app/knowledge/pneumonia.md new file mode 100644 index 0000000..bb5b542 --- /dev/null +++ b/ai-service/app/knowledge/pneumonia.md @@ -0,0 +1,23 @@ +# 社区获得性肺炎辅助诊疗要点 + +category: 呼吸内科 + +## 概述 +社区获得性肺炎(CAP)是指在医院外罹患的肺实质感染,常见症状包括发热、咳嗽、咳痰、胸痛与呼吸困难。 + +## 影像表现 +- X 线/CT:片状、斑片状浸润影或实变,可伴空气支气管征。 +- 需与肺结核、肿瘤、肺水肿等鉴别。 + +## 治疗原则 +- 评估 CURB-65 或 PSI 严重程度分层。 +- 经验性抗感染覆盖常见病原(肺炎链球菌等),并根据培养结果调整。 +- 支持治疗:氧疗、补液、退热、止咳化痰。 + +## 护理与随访 +- 监测体温、呼吸频率、血氧饱和度。 +- 鼓励有效咳嗽与体位引流。 +- 治疗后 3–5 天评估疗效;必要时复查胸片。 + +## 注意 +本资料仅供教学演示与辅助决策参考,不能替代临床指南与医师判断。 diff --git a/ai-service/app/knowledge/pulmonary_nodule.md b/ai-service/app/knowledge/pulmonary_nodule.md new file mode 100644 index 0000000..b2c41c3 --- /dev/null +++ b/ai-service/app/knowledge/pulmonary_nodule.md @@ -0,0 +1,23 @@ +# 肺结节随访与管理要点 + +category: 呼吸/胸外 + +## 定义 +肺结节通常指直径 ≤3 cm 的圆形或类圆形 dens 灶,需结合大小、形态、密度及随访变化综合判断。 + +## 影像关注点 +- 大小与倍增时间 +- 边缘(毛刺、分叶)、钙化、空泡、胸膜牵拉 +- 实性 / 部分实性 / 磨玻璃 + +## 管理建议(示意) +- 微小结节:定期 CT 随访。 +- 可疑恶性特征:建议专科评估,必要时 PET、活检或多学科讨论。 +- 戒烟是重要干预措施。 + +## 护理与宣教 +- 缓解患者焦虑,说明随访意义。 +- 出现咯血、胸痛、进行性气促及时就诊。 + +## 注意 +本资料仅供教学演示,实际随访间隔需按最新指南与个体情况确定。 diff --git a/ai-service/app/main.py b/ai-service/app/main.py new file mode 100644 index 0000000..d8a2656 --- /dev/null +++ b/ai-service/app/main.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import logging + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api import health, imaging, llm_config, rag, report, yolo +from app.config import get_settings +from app.services.llm_client import get_llm +from app.services.rag_pipeline import get_rag +from app.services.yolo_detector import get_detector + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s - %(message)s", +) +logger = logging.getLogger("ai-service") + +settings = get_settings() + +app = FastAPI( + title="Smart Hospital AI Service", + description="YOLO/MONAI 影像识别 + LangChain RAG + 报告/决策生成(实训演示)", + version="1.0.0", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(health.router) +app.include_router(imaging.router) +app.include_router(rag.router) +app.include_router(report.router) +app.include_router(yolo.router) +app.include_router(llm_config.router) + + +@app.on_event("startup") +def on_startup() -> None: + rag_pipe = get_rag() + det_info = get_detector().info() + llm_info = get_llm().info() + logger.info( + "AI 服务启动 port=%s knowledge_chunks=%s llm=%s yolo_mode=%s", + settings.ai_port, + rag_pipe.doc_count(), + llm_info.get("enabled"), + det_info.get("runtime_mode"), + ) + + +@app.get("/") +def root() -> dict: + return { + "service": "smart-hospital-ai", + "docs": "/docs", + "health": "/health", + } + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "app.main:app", + host=settings.ai_host, + port=settings.ai_port, + reload=False, + ) diff --git a/ai-service/app/schemas/__init__.py b/ai-service/app/schemas/__init__.py new file mode 100644 index 0000000..0baeeba --- /dev/null +++ b/ai-service/app/schemas/__init__.py @@ -0,0 +1,27 @@ +from .models import ( + Detection, + ImagingAnalyzeResponse, + ImagingReportRequest, + ImagingReportResponse, + DecisionRequest, + DecisionResponse, + RiskItem, + SourceRef, + RagQueryRequest, + RagQueryResponse, + HealthResponse, +) + +__all__ = [ + "Detection", + "ImagingAnalyzeResponse", + "ImagingReportRequest", + "ImagingReportResponse", + "DecisionRequest", + "DecisionResponse", + "RiskItem", + "SourceRef", + "RagQueryRequest", + "RagQueryResponse", + "HealthResponse", +] diff --git a/ai-service/app/schemas/models.py b/ai-service/app/schemas/models.py new file mode 100644 index 0000000..86d3964 --- /dev/null +++ b/ai-service/app/schemas/models.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field + + +class Detection(BaseModel): + label: str + confidence: float + bbox: list[float] = Field(description="[x1, y1, x2, y2] 像素坐标") + label_zh: Optional[str] = None + + +class ImagingAnalyzeResponse(BaseModel): + detections: list[Detection] = [] + annotated_image_base64: Optional[str] = None + preliminary_diagnosis: str + findings: str + recommendations: str + confidence: float + model_version: str + mode: Literal["demo", "real"] = "demo" + full_report: Optional[str] = None + disclaimer: str = "本结果仅供辅助决策,不能替代执业医师诊断。" + + +class ImagingReportRequest(BaseModel): + study_type: str = "CT" + body_part: str = "" + patient_summary: str = "" + preliminary_diagnosis: str = "" + findings: str = "" + detections: list[Detection] = [] + confidence: float = 0.85 + + +class ImagingReportResponse(BaseModel): + findings: str + impression: str + recommendations: str + full_report: str + model_version: str = "report-v1" + + +class PatientInfo(BaseModel): + age: Optional[int] = None + gender: Optional[str] = None + name: Optional[str] = None + + +class DecisionRequest(BaseModel): + chief_complaint: str = "" + history: str = "" + exam_findings: str = "" + diagnosis: str = "" + medications: str = "" + imaging_summary: str = "" + patient: Optional[PatientInfo] = None + + +class RiskItem(BaseModel): + type: str + description: str + level: str = "中" + confidence: float = 0.8 + + +class SourceRef(BaseModel): + title: str + snippet: str = "" + category: str = "" + score: float = 0.0 + + +class DecisionResponse(BaseModel): + treatment_suggestions: list[dict[str, Any]] = [] + medication_suggestions: list[dict[str, Any]] = [] + nursing_advice: list[str] = [] + follow_up_plan: list[str] = [] + risks: list[RiskItem] = [] + conflicts: list[str] = [] + sources: list[SourceRef] = [] + full_text: str = "" + engine: str = "fastapi-rag" + disclaimer: str = "本结果仅供辅助决策,不能替代执业医师诊断。" + + +class RagQueryRequest(BaseModel): + query: str + top_k: int = 4 + context: str = "" + + +class RagQueryResponse(BaseModel): + answer: str + sources: list[SourceRef] = [] + engine: str = "langchain-rag" + + +class HealthResponse(BaseModel): + status: str = "ok" + yolo_available: bool = False + monai_available: bool = False + langchain_available: bool = False + llm_configured: bool = False + demo_mode: str = "auto" + knowledge_docs: int = 0 diff --git a/ai-service/app/services/__init__.py b/ai-service/app/services/__init__.py new file mode 100644 index 0000000..c6f2d23 --- /dev/null +++ b/ai-service/app/services/__init__.py @@ -0,0 +1 @@ +"""AI service implementations.""" diff --git a/ai-service/app/services/llm_client.py b/ai-service/app/services/llm_client.py new file mode 100644 index 0000000..59b5b36 --- /dev/null +++ b/ai-service/app/services/llm_client.py @@ -0,0 +1,142 @@ +"""OpenAI 兼容 LLM 客户端(DeepSeek / Qwen)。支持 .env + 管理端运行时覆盖。""" +from __future__ import annotations + +import json +import logging +import re +from typing import Any + +import httpx + +from app.config import Settings, get_settings +from app.services.llm_runtime import get_runtime_llm + +logger = logging.getLogger(__name__) + + +class LlmClient: + def __init__(self, settings: Settings | None = None): + self.settings = settings or get_settings() + + def _effective_key(self) -> str: + rt = get_runtime_llm() + if rt.api_key is not None: + return rt.api_key.strip() + return (self.settings.llm_api_key or "").strip() + + def _effective_base_url(self) -> str: + rt = get_runtime_llm() + if rt.api_base_url: + return rt.api_base_url.rstrip("/") + return (self.settings.llm_base_url or "https://api.deepseek.com").rstrip("/") + + def _effective_model(self) -> str: + rt = get_runtime_llm() + if rt.model: + return rt.model + return self.settings.llm_model or "deepseek-chat" + + def _effective_temperature(self, override: float | None = None) -> float: + if override is not None: + return override + rt = get_runtime_llm() + if rt.temperature is not None: + return float(rt.temperature) + return float(self.settings.llm_temperature) + + @property + def enabled(self) -> bool: + """管理端 enabled=false 强制关闭;否则有可用 API Key 即启用。""" + key = self._effective_key() + if not key: + return False + rt = get_runtime_llm() + if rt.enabled is False: + return False + if rt.enabled is True: + return True + # 未下发 enabled 时:有 key(env 或 runtime)即视为可用 + return True + + def info(self) -> dict[str, Any]: + rt = get_runtime_llm() + return { + "enabled": self.enabled, + "api_key_configured": bool(self._effective_key()), + "api_base_url": self._effective_base_url(), + "model": self._effective_model(), + "temperature": self._effective_temperature(), + "source": rt.source if (rt.api_key or rt.enabled is not None) else "env", + } + + def chat(self, messages: list[dict[str, str]], temperature: float | None = None) -> str: + if not self.enabled: + raise RuntimeError("未配置 LLM(请在管理端「AI 配置」启用并填写 API Key,或设置 ai-service/.env 的 LLM_API_KEY)") + base = self._effective_base_url() + if base.endswith("/v1"): + url = base + "/chat/completions" + else: + url = base + "/v1/chat/completions" + payload = { + "model": self._effective_model(), + "temperature": self._effective_temperature(temperature), + "messages": messages, + } + headers = { + "Authorization": f"Bearer {self._effective_key()}", + "Content-Type": "application/json", + } + with httpx.Client(timeout=90.0) as client: + resp = client.post(url, headers=headers, json=payload) + if resp.status_code >= 400: + raise RuntimeError(f"LLM HTTP {resp.status_code}: {resp.text[:300]}") + data = resp.json() + content = ( + data.get("choices", [{}])[0] + .get("message", {}) + .get("content", "") + ) + if not content: + raise RuntimeError("LLM 返回空内容") + return content.strip() + + def chat_json(self, messages: list[dict[str, str]]) -> dict[str, Any]: + text = self.chat(messages, temperature=0.2) + return extract_json(text) + + +def extract_json(text: str) -> dict[str, Any]: + text = text.strip() + try: + return json.loads(text) + except json.JSONDecodeError: + pass + fence = re.search(r"```(?:json)?\s*([\s\S]*?)```", text) + if fence: + try: + return json.loads(fence.group(1).strip()) + except json.JSONDecodeError: + pass + start, end = text.find("{"), text.rfind("}") + if start >= 0 and end > start: + try: + return json.loads(text[start : end + 1]) + except json.JSONDecodeError: + pass + raise ValueError("无法从模型输出解析 JSON") + + +_llm: LlmClient | None = None + + +def get_llm() -> LlmClient: + global _llm + if _llm is None: + _llm = LlmClient() + return _llm + + +def reset_llm_client() -> None: + """测试或热更新后可重置单例(配置本身已从 runtime 动态读取,一般无需调用)。""" + global _llm + _llm = None diff --git a/ai-service/app/services/llm_runtime.py b/ai-service/app/services/llm_runtime.py new file mode 100644 index 0000000..2f2012f --- /dev/null +++ b/ai-service/app/services/llm_runtime.py @@ -0,0 +1,84 @@ +"""运行时 LLM 配置:可由业务后端(管理端 AI 配置)动态下发,覆盖 .env。""" +from __future__ import annotations + +from dataclasses import dataclass, field +from threading import RLock +from typing import Any + + +@dataclass +class RuntimeLlmConfig: + """enabled=None 表示未由管理端覆盖,沿用 .env。""" + + enabled: bool | None = None + api_base_url: str | None = None + api_key: str | None = None + model: str | None = None + temperature: float | None = None + source: str = "env" + + +_lock = RLock() +_runtime = RuntimeLlmConfig() + + +def get_runtime_llm() -> RuntimeLlmConfig: + with _lock: + return RuntimeLlmConfig( + enabled=_runtime.enabled, + api_base_url=_runtime.api_base_url, + api_key=_runtime.api_key, + model=_runtime.model, + temperature=_runtime.temperature, + source=_runtime.source, + ) + + +def update_runtime_llm( + *, + enabled: bool | None = None, + api_base_url: str | None = None, + api_key: str | None = None, + model: str | None = None, + temperature: float | None = None, +) -> RuntimeLlmConfig: + """ + 更新运行时配置。 + - api_key 为 None:不改密钥 + - api_key 为非空字符串:覆盖 + - api_key 为 "":清空运行时密钥(回退 .env) + """ + with _lock: + if enabled is not None: + _runtime.enabled = bool(enabled) + if api_base_url is not None and api_base_url.strip(): + _runtime.api_base_url = api_base_url.strip() + if api_key is not None: + _runtime.api_key = api_key.strip() if api_key.strip() else None + if model is not None and model.strip(): + _runtime.model = model.strip() + if temperature is not None: + t = float(temperature) + _runtime.temperature = max(0.0, min(2.0, t)) + _runtime.source = "runtime" + return get_runtime_llm() + + +def runtime_status(env_key_configured: bool, env_base: str, env_model: str) -> dict[str, Any]: + rt = get_runtime_llm() + key_ok = bool(rt.api_key) or env_key_configured + enabled = False + if key_ok: + if rt.enabled is None: + enabled = env_key_configured or bool(rt.api_key) + else: + enabled = bool(rt.enabled) + return { + "enabled": enabled, + "configured": key_ok, + "api_key_configured": key_ok, + "api_base_url": rt.api_base_url or env_base, + "model": rt.model or env_model, + "temperature": rt.temperature, + "source": rt.source if (rt.api_key or rt.enabled is not None or rt.api_base_url) else "env", + } diff --git a/ai-service/app/services/monai_preprocess.py b/ai-service/app/services/monai_preprocess.py new file mode 100644 index 0000000..8700f04 --- /dev/null +++ b/ai-service/app/services/monai_preprocess.py @@ -0,0 +1,78 @@ +"""医学影像预处理:优先 MONAI,失败则用 OpenCV/Pillow 降级。""" +from __future__ import annotations + +import logging +from typing import Any + +import cv2 +import numpy as np + +logger = logging.getLogger(__name__) + +_MONAI_OK = False +try: + import monai # noqa: F401 + from monai.transforms import Compose, ScaleIntensity, Resize + + _MONAI_OK = True +except Exception: # pragma: no cover + _MONAI_OK = False + logger.info("MONAI 未安装,使用 OpenCV 预处理管线") + + +def monai_available() -> bool: + return _MONAI_OK + + +def load_image_bgr(image_bytes: bytes) -> np.ndarray: + arr = np.frombuffer(image_bytes, dtype=np.uint8) + img = cv2.imdecode(arr, cv2.IMREAD_COLOR) + if img is None: + raise ValueError("无法解码影像文件,请上传常见图片格式(jpg/png 等)") + return img + + +def preprocess(image_bgr: np.ndarray, target_size: int = 640) -> dict[str, Any]: + """ + 返回: + - image_bgr: 原始 BGR + - image_rgb: RGB + - tensor_like: 归一化后的 float32 CHW(MONAI 或 numpy 模拟) + - meta: 尺寸信息 + """ + h, w = image_bgr.shape[:2] + rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB) + + if _MONAI_OK: + try: + # 灰度/三通道统一为 CHW float,再经 MONAI ScaleIntensity + Resize + chw = np.transpose(rgb.astype(np.float32) / 255.0, (2, 0, 1)) + transforms = Compose( + [ + ScaleIntensity(minv=0.0, maxv=1.0), + Resize(spatial_size=(target_size, target_size), mode="bilinear"), + ] + ) + tensor = transforms(chw) + if hasattr(tensor, "numpy"): + tensor = tensor.numpy() + return { + "image_bgr": image_bgr, + "image_rgb": rgb, + "tensor_like": np.asarray(tensor), + "backend": "monai", + "meta": {"orig_h": h, "orig_w": w, "target": target_size}, + } + except Exception as e: # pragma: no cover + logger.warning("MONAI 预处理失败,降级 OpenCV: %s", e) + + # OpenCV 降级:resize + normalize + resized = cv2.resize(rgb, (target_size, target_size), interpolation=cv2.INTER_LINEAR) + tensor = np.transpose(resized.astype(np.float32) / 255.0, (2, 0, 1)) + return { + "image_bgr": image_bgr, + "image_rgb": rgb, + "tensor_like": tensor, + "backend": "opencv", + "meta": {"orig_h": h, "orig_w": w, "target": target_size}, + } diff --git a/ai-service/app/services/rag_pipeline.py b/ai-service/app/services/rag_pipeline.py new file mode 100644 index 0000000..ba0f923 --- /dev/null +++ b/ai-service/app/services/rag_pipeline.py @@ -0,0 +1,242 @@ +"""LangChain 风格 RAG:文档切分 + 关键词检索 + 可选 LLM 生成。""" +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from typing import Any + +from app.config import Settings, get_settings +from app.schemas.models import SourceRef +from app.services.llm_client import get_llm + +logger = logging.getLogger(__name__) + +_LC_OK = False +try: + from langchain_core.documents import Document + from langchain_text_splitters import RecursiveCharacterTextSplitter + + _LC_OK = True +except Exception: # pragma: no cover + Document = None # type: ignore + RecursiveCharacterTextSplitter = None # type: ignore + logger.info("LangChain 未完全安装,将使用内置简易检索") + + +def langchain_available() -> bool: + return _LC_OK + + +@dataclass +class Chunk: + title: str + category: str + content: str + source: str + + +class RagPipeline: + def __init__(self, settings: Settings | None = None): + self.settings = settings or get_settings() + self.chunks: list[Chunk] = [] + self._load_knowledge() + + def _load_knowledge(self) -> None: + path = self.settings.knowledge_path + path.mkdir(parents=True, exist_ok=True) + files = sorted(list(path.glob("*.md")) + list(path.glob("*.txt"))) + raw_docs: list[tuple[str, str, str]] = [] + for f in files: + try: + text = f.read_text(encoding="utf-8") + except Exception: + continue + title, category, body = self._parse_doc(f.stem, text) + raw_docs.append((title, category, body)) + + if not raw_docs: + logger.warning("知识库目录为空: %s", path) + self.chunks = [] + return + + if _LC_OK: + splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=80) + for title, category, body in raw_docs: + docs = splitter.split_documents( + [Document(page_content=body, metadata={"title": title, "category": category})] + ) + for d in docs: + self.chunks.append( + Chunk( + title=title, + category=category, + content=d.page_content, + source=title, + ) + ) + else: + for title, category, body in raw_docs: + for part in self._simple_split(body, 500): + self.chunks.append( + Chunk(title=title, category=category, content=part, source=title) + ) + + logger.info("知识库已加载 %d 个文档片段", len(self.chunks)) + + @staticmethod + def _parse_doc(stem: str, text: str) -> tuple[str, str, str]: + title = stem + category = "临床指南" + body = text.strip() + lines = body.splitlines() + if lines and lines[0].startswith("#"): + title = lines[0].lstrip("#").strip() or stem + body = "\n".join(lines[1:]).strip() + m = re.search(r"category:\s*(.+)", body, re.I) + if m: + category = m.group(1).strip() + return title, category, body + + @staticmethod + def _simple_split(text: str, size: int) -> list[str]: + if len(text) <= size: + return [text] + parts: list[str] = [] + i = 0 + while i < len(text): + parts.append(text[i : i + size]) + i += max(1, size - 50) + return parts + + def reload(self) -> int: + self.chunks = [] + self._load_knowledge() + return len(self.chunks) + + def doc_count(self) -> int: + return len(self.chunks) + + def retrieve(self, query: str, top_k: int = 4) -> list[SourceRef]: + if not query or not self.chunks: + return [] + tokens = self._tokenize(query) + scored: list[tuple[float, Chunk]] = [] + q = query.lower() + for ch in self.chunks: + score = self._score(ch, tokens, q) + if score > 0: + scored.append((score, ch)) + scored.sort(key=lambda x: x[0], reverse=True) + results: list[SourceRef] = [] + seen: set[tuple[str, str]] = set() + for score, ch in scored: + key = (ch.title, ch.content[:80]) + if key in seen: + continue + seen.add(key) + results.append( + SourceRef( + title=ch.title, + category=ch.category, + snippet=ch.content[:220].replace("\n", " "), + score=round(score, 2), + ) + ) + if len(results) >= top_k: + break + return results + + def build_context(self, sources: list[SourceRef]) -> str: + if not sources: + return "" + parts = [] + for i, s in enumerate(sources, 1): + parts.append(f"【资料{i}】{s.title}\n{s.snippet}") + return "\n\n".join(parts) + + def query(self, query: str, top_k: int = 4, extra_context: str = "") -> dict[str, Any]: + sources = self.retrieve(query, top_k=top_k) + context = self.build_context(sources) + if extra_context: + context = (extra_context.strip() + "\n\n" + context).strip() + + llm = get_llm() + if llm.enabled and (context or query): + try: + answer = llm.chat( + [ + { + "role": "system", + "content": ( + "你是医院临床辅助决策助手。请仅依据给定资料与问题作答," + "语言专业简洁,并提醒需医师审核。不要编造未提供的检查数据。" + ), + }, + { + "role": "user", + "content": f"参考资料:\n{context or '(无)'}\n\n问题:{query}", + }, + ] + ) + return {"answer": answer, "sources": sources, "engine": "langchain-rag+llm"} + except Exception as e: + logger.warning("RAG LLM 失败: %s", e) + + if sources: + answer = ( + f"基于本地知识库检索(LangChain 文档切分 + 关键词排序),与「{query}」相关的要点:\n" + + "\n".join(f"- {s.title}:{s.snippet[:120]}" for s in sources) + + "\n\n(未配置大模型 API Key 或调用失败时展示检索摘要,仅供参考。)" + ) + return {"answer": answer, "sources": sources, "engine": "langchain-rag-local"} + + return { + "answer": f"知识库中未检索到与「{query}」高度相关的条目,建议补充临床指南或完善病历描述。", + "sources": [], + "engine": "langchain-rag-empty", + } + + def ingest_text(self, title: str, content: str, category: str = "自定义") -> None: + path = self.settings.knowledge_path + path.mkdir(parents=True, exist_ok=True) + safe = re.sub(r"[^\w\u4e00-\u9fff\-]+", "_", title)[:40] or "doc" + file_path = path / f"{safe}.md" + file_path.write_text( + f"# {title}\n\ncategory: {category}\n\n{content}\n", + encoding="utf-8", + ) + self.reload() + + @staticmethod + def _tokenize(text: str) -> list[str]: + parts = re.split(r"[\s,,。.!!??;;::、/\\|_\-—()()\[\]{}]+", text.lower()) + return [p for p in parts if len(p) >= 2] + + @staticmethod + def _score(ch: Chunk, tokens: list[str], q: str) -> float: + title = ch.title.lower() + content = ch.content.lower() + score = 0.0 + if q and q in title: + score += 30 + if q and q in content: + score += 15 + for t in tokens: + if t in title: + score += 8 + if t in content: + score += 3 + if t in ch.category.lower(): + score += 2 + return score + + +_rag: RagPipeline | None = None + + +def get_rag() -> RagPipeline: + global _rag + if _rag is None: + _rag = RagPipeline() + return _rag diff --git a/ai-service/app/services/report_generator.py b/ai-service/app/services/report_generator.py new file mode 100644 index 0000000..6364da5 --- /dev/null +++ b/ai-service/app/services/report_generator.py @@ -0,0 +1,417 @@ +"""影像报告与临床决策建议生成。""" +from __future__ import annotations + +import logging +import re +from typing import Any + +from app.schemas.models import ( + DecisionRequest, + DecisionResponse, + Detection, + ImagingReportRequest, + ImagingReportResponse, + RiskItem, + SourceRef, +) +from app.services.llm_client import get_llm +from app.services.rag_pipeline import get_rag + +logger = logging.getLogger(__name__) + +STUDY_LABEL = { + "X_RAY": "X 光", + "CT": "CT", + "MRI": "MRI", + "ULTRASOUND": "超声", +} + + +def build_imaging_texts( + study_type: str, + body_part: str, + detections: list[Detection], + mode: str, +) -> tuple[str, str, str, float]: + """返回 findings, diagnosis, recommendations, confidence。""" + st = STUDY_LABEL.get(study_type.upper(), study_type) + part = body_part or "相关部位" + if not detections: + findings = f"{st}检查({part}):影像质量可评估,未见明确异常密度/信号灶。" + diagnosis = f"{part}{st}未见明显异常" + rec = "建议结合临床,必要时复查或进一步检查。" + return findings, diagnosis, rec, 0.82 + + lines = [] + for d in detections: + name = d.label_zh or d.label + lines.append( + f"- 可见{name}样改变,框选区域约 ({int(d.bbox[0])},{int(d.bbox[1])})-" + f"({int(d.bbox[2])},{int(d.bbox[3])}),模型置信度 {d.confidence:.0%}" + ) + findings = f"{st}检查({part})AI 辅助读片所见:\n" + "\n".join(lines) + top = max(detections, key=lambda x: x.confidence) + diagnosis = f"{part}可疑{top.label_zh or top.label},建议专科医师复核" + rec = _rec_for_label(top.label) + conf = sum(d.confidence for d in detections) / len(detections) + if mode == "demo": + findings += "\n(演示模式:检测框由 YOLO 演示引擎生成,非临床验证模型输出)" + return findings, diagnosis, rec, round(min(0.98, conf), 4) + + +def _rec_for_label(label: str) -> str: + mapping = { + "opacity": "建议结合血常规/炎症指标,必要时抗感染治疗并短期复查胸片。", + "nodule": "建议按结节指南分层管理,3 个月后复查 CT,必要时多学科会诊。", + "fracture": "建议骨科评估,必要时制动/固定,复查局部 X 光。", + "effusion": "建议评估积液性质,必要时穿刺或超声随访。", + "lesion": "建议结合临床与实验室检查,必要时增强扫描或专科转诊。", + "mass": "建议进一步定性检查,排除占位性病变,及时专科就诊。", + "calcification": "多为良性钙化可能,建议定期随访观察。", + } + return mapping.get(label, "建议专科医师综合临床资料判读,制定个体化方案。") + + +def _normalize_multiline(text: str) -> str: + """把挤成一段的长文尽量拆成可读多行(句号/分号后换行,编号建议分行)。""" + if not text: + return "" + s = str(text).strip() + # 已有明显换行则只做空白整理 + if "\n" in s and s.count("\n") >= 2: + return "\n".join(line.strip() for line in s.splitlines() if line.strip()) + + # 编号建议:1. / 1、 / (1) 前换行 + s = re.sub(r"(? str: + """建议统一为多行编号列表。""" + if not text: + return "" + s = str(text).strip() + # 已是多行编号 + if re.search(r"(?m)^\s*[((]?\d+[\.、))]", s): + return "\n".join(ln.strip() for ln in s.splitlines() if ln.strip()) + + # 行内编号:1. / 1、 / (1) + items = re.findall( + r"[((]?([1-9]\d?)[\.、))]\s*([^((]*?)(?=(?:[((]?[1-9]\d?[\.、))])|$)", + s, + ) + cleaned = [(idx, t.strip(" ;;。 \t")) for idx, t in items if t.strip(" ;;。 \t")] + if len(cleaned) >= 2: + return "\n".join(f"{i}. {t}" for i, (_, t) in enumerate(cleaned, 1)) + + # 按分号切成条目 + chunks = [c.strip(" ;;。") for c in re.split(r"[;;]", s) if c.strip(" ;;。")] + if len(chunks) >= 2: + return "\n".join(f"{i}. {c}" for i, c in enumerate(chunks, 1)) + return s + + +def make_full_report( + study_type: str, + body_part: str, + findings: str, + impression: str, + recommendations: str, + patient_summary: str = "", +) -> str: + """结构化完整报告:固定四段,便于前端分段渲染。""" + st = STUDY_LABEL.get(study_type.upper(), study_type) + findings_n = _normalize_multiline(findings) + impression_n = _normalize_multiline(impression) or impression + rec_n = _normalize_recommendations(recommendations) or recommendations + + header = [ + "【影像诊断报告(AI 辅助)】", + f"检查类型:{st}", + f"检查部位:{body_part or '—'}", + ] + if patient_summary: + header.append(f"临床摘要:{patient_summary}") + + sections = [ + "\n".join(header), + "一、影像所见\n" + (findings_n or "—"), + "二、诊断印象\n" + (impression_n or "—"), + "三、建议\n" + (rec_n or "—"), + "四、声明\n本报告由 AI 辅助生成,仅供临床参考,需执业医师审核,不能替代正式报告。", + ] + return "\n\n".join(sections) + + +def generate_imaging_report(req: ImagingReportRequest) -> ImagingReportResponse: + llm = get_llm() + if llm.enabled: + try: + det_lines = [] + for d in req.detections: + name = d.label_zh or d.label + box = ",".join(str(int(x)) for x in d.bbox[:4]) if d.bbox else "-" + det_lines.append(f"{name} conf={d.confidence:.0%} box=[{box}]") + data = llm.chat_json( + [ + { + "role": "system", + "content": ( + "你是三甲医院影像科辅助报告生成器。" + "必须只输出一个 JSON 对象(不要 markdown 代码块),字段:" + "findings(影像所见:多段文字,用换行分隔;先写检查方法与部位," + "再写病灶描述,再写其余部位阴性所见,勿写成一整段)、" + "impression(诊断印象:1~3 句,可换行)、" + "recommendations(建议:必须用换行的编号列表,如 " + "'1. ...\\n2. ...\\n3. ...',含进一步检查/随访/会诊)、" + "full_report 不要输出(由系统按分段模板拼接)。" + "依据 YOLO 检测结果撰写,专业简洁;" + "明确写明需执业医师审核,不能替代正式报告。" + "禁止编造未提供的患者检验结果。" + ), + }, + { + "role": "user", + "content": ( + f"检查类型={req.study_type}\n" + f"检查部位={req.body_part or '未注明'}\n" + f"患者摘要={req.patient_summary or '无'}\n" + f"规则初诊={req.preliminary_diagnosis}\n" + f"规则所见={req.findings}\n" + f"检测列表:\n" + ("\n".join(det_lines) if det_lines else "(无检出)") + ), + }, + ] + ) + findings = _normalize_multiline(str(data.get("findings") or req.findings).strip()) + impression = _normalize_multiline( + str( + data.get("impression") + or data.get("preliminary_diagnosis") + or req.preliminary_diagnosis + ).strip() + ) + rec = _normalize_recommendations( + str(data.get("recommendations") or "建议专科医师复核。").strip() + ) + # 始终用分段模板拼完整报告,避免 LLM 输出一整段墙文本 + full = make_full_report( + req.study_type, req.body_part, findings, impression, rec, req.patient_summary + ) + model_name = llm.info().get("model") or "llm" + logger.info("影像报告已由 LLM 生成 model=%s", model_name) + return ImagingReportResponse( + findings=findings, + impression=impression, + recommendations=rec, + full_report=full, + model_version=f"report-llm:{model_name}", + ) + except Exception as e: + logger.warning("影像报告 LLM 失败,回退模板: %s", e) + + logger.info("影像报告使用模板模式(LLM 未启用或调用失败) llm_enabled=%s", llm.enabled) + # recommendations 留空,由 imaging API 回退到 build_imaging_texts 的按病灶建议 + full = make_full_report( + req.study_type, + req.body_part, + req.findings, + req.preliminary_diagnosis, + "建议结合临床,由影像科/临床医师最终签发。", + req.patient_summary, + ) + return ImagingReportResponse( + findings=req.findings, + impression=req.preliminary_diagnosis, + recommendations="", + full_report=full, + model_version="report-template", + ) + + +def generate_decision(req: DecisionRequest) -> DecisionResponse: + rag = get_rag() + query = " ".join( + x for x in [req.diagnosis, req.chief_complaint, req.history, req.imaging_summary] if x + ).strip() or "常见病辅助决策" + sources = rag.retrieve(query, top_k=4) + context = rag.build_context(sources) + patient = req.patient + patient_desc = "" + if patient: + patient_desc = f"年龄={patient.age} 性别={patient.gender} 姓名={patient.name or ''}" + + llm = get_llm() + if llm.enabled: + try: + data = llm.chat_json( + [ + { + "role": "system", + "content": ( + "你是临床辅助决策系统。输出严格 JSON,字段:" + "treatment_suggestions(数组,元素含 title,description,confidence)," + "medication_suggestions(数组,元素含 name,dosage,category,confidence)," + "nursing_advice(字符串数组)," + "follow_up_plan(字符串数组)," + "risks(数组,元素含 type,description,level,confidence)," + "conflicts(字符串数组)," + "full_text(字符串)。" + "必须提醒需医师审核;勿编造不存在的检查结果。" + ), + }, + { + "role": "user", + "content": ( + f"患者:{patient_desc}\n" + f"主诉:{req.chief_complaint}\n" + f"病史:{req.history}\n" + f"查体:{req.exam_findings}\n" + f"诊断:{req.diagnosis}\n" + f"用药:{req.medications}\n" + f"影像摘要:{req.imaging_summary}\n" + f"知识库:\n{context or '无'}" + ), + }, + ] + ) + return _map_decision(data, sources, engine="fastapi-rag+llm") + except Exception as e: + logger.warning("决策 LLM 失败: %s", e) + + return _template_decision(req, sources) + + +def _map_decision(data: dict[str, Any], sources: list[SourceRef], engine: str) -> DecisionResponse: + risks = [] + for r in data.get("risks") or []: + if isinstance(r, dict): + risks.append( + RiskItem( + type=str(r.get("type") or "风险"), + description=str(r.get("description") or ""), + level=str(r.get("level") or "中"), + confidence=float(r.get("confidence") or 0.8), + ) + ) + return DecisionResponse( + treatment_suggestions=list(data.get("treatment_suggestions") or []), + medication_suggestions=list(data.get("medication_suggestions") or []), + nursing_advice=[str(x) for x in (data.get("nursing_advice") or [])], + follow_up_plan=[str(x) for x in (data.get("follow_up_plan") or [])], + risks=risks, + conflicts=[str(x) for x in (data.get("conflicts") or [])], + sources=sources, + full_text=str(data.get("full_text") or ""), + engine=engine, + ) + + +def _template_decision(req: DecisionRequest, sources: list[SourceRef]) -> DecisionResponse: + dx = req.diagnosis or "" + treatments: list[dict[str, Any]] = [] + meds: list[dict[str, Any]] = [] + nursing: list[str] = [] + follow: list[str] = [] + risks: list[RiskItem] = [] + + if "高血压" in dx: + treatments = [ + {"title": "生活方式干预", "description": "低盐饮食,适量有氧运动,控制体重,戒烟限酒", "confidence": 0.95}, + {"title": "药物治疗", "description": "可考虑 ACEI/ARB 或 CCB 作为一线方案(需医师确认)", "confidence": 0.9}, + ] + meds = [ + {"name": "氨氯地平", "dosage": "5mg qd", "category": "钙通道阻滞剂", "confidence": 0.9}, + {"name": "缬沙坦", "dosage": "80mg qd", "category": "ARB", "confidence": 0.88}, + ] + nursing = ["监测血压并记录", "宣教服药依从性", "观察头晕、乏力等低血压症状"] + follow = ["1–2 周门诊复查血压", "评估靶器官损害相关检查"] + elif "糖尿病" in dx: + treatments = [ + {"title": "饮食运动", "description": "控制总热量与碳水,规律运动", "confidence": 0.95}, + {"title": "降糖治疗", "description": "二甲双胍等一线方案需结合肾功能与禁忌", "confidence": 0.9}, + ] + meds = [{"name": "二甲双胍", "dosage": "0.5g tid", "category": "双胍类", "confidence": 0.92}] + nursing = ["血糖监测指导", "足部护理宣教", "低血糖识别与处理"] + follow = ["2–4 周复诊评估血糖", "定期查 HbA1c"] + elif "肺炎" in dx or "阴影" in dx: + treatments = [ + {"title": "抗感染", "description": "根据社区/医院获得性肺炎指南选择抗生素", "confidence": 0.88}, + {"title": "支持治疗", "description": "休息、补液、必要时氧疗", "confidence": 0.92}, + ] + meds = [{"name": "阿莫西林", "dosage": "0.5g tid", "category": "青霉素类", "confidence": 0.85}] + nursing = ["监测体温与呼吸", "叩背排痰指导", "隔离防护宣教(如需要)"] + follow = ["3–5 天评估疗效", "必要时复查胸片"] + elif "结节" in dx: + treatments = [ + {"title": "分层随访", "description": "按结节大小与特征选择随访或进一步检查", "confidence": 0.9}, + ] + nursing = ["戒烟宣教", "避免焦虑,说明随访意义"] + follow = ["3 个月复查 CT", "出现咯血/胸痛及时就诊"] + else: + treatments = [ + {"title": "进一步评估", "description": "完善相关检查以明确诊断", "confidence": 0.85}, + {"title": "对症处理", "description": "根据症状给予相应支持治疗", "confidence": 0.88}, + ] + nursing = ["观察病情变化", "用药与生活方式宣教"] + follow = ["按病情 1–2 周复诊", "出现加重症状及时急诊"] + + if req.patient and req.patient.age and req.patient.age >= 65: + risks.append( + RiskItem( + type="高龄风险", + description="高龄患者需注意剂量调整、跌倒与多药联用风险", + level="高", + confidence=0.85, + ) + ) + if "高血压" in dx and req.patient and req.patient.age and req.patient.age > 60: + risks.append( + RiskItem( + type="心血管风险", + description="高血压合并高龄,心血管事件风险增加", + level="中", + confidence=0.8, + ) + ) + + conflicts: list[str] = [] + meds_text = req.medications or "" + if "华法林" in meds_text and "阿司匹林" in meds_text: + conflicts.append("警告:华法林与阿司匹林联合使用可能增加出血风险") + if "ACEI" in meds_text and "保钾" in meds_text: + conflicts.append("注意:ACEI 与保钾利尿剂联用可能致高钾血症") + + src_hint = "" + if sources: + src_hint = "\n知识库参考:" + ";".join(s.title for s in sources[:3]) + + full = ( + f"诊断相关辅助建议(规则+RAG):{dx or '未明确'}\n" + f"治疗:{'; '.join(t['title'] for t in treatments)}\n" + f"护理:{';'.join(nursing)}\n" + f"随访:{';'.join(follow)}" + f"{src_hint}\n" + "(模板模式,可配置 LLM_API_KEY 启用大模型增强)" + ) + return DecisionResponse( + treatment_suggestions=treatments, + medication_suggestions=meds, + nursing_advice=nursing, + follow_up_plan=follow, + risks=risks, + conflicts=conflicts, + sources=sources, + full_text=full, + engine="fastapi-rag-template", + ) diff --git a/ai-service/app/services/yolo_detector.py b/ai-service/app/services/yolo_detector.py new file mode 100644 index 0000000..e6c9e06 --- /dev/null +++ b/ai-service/app/services/yolo_detector.py @@ -0,0 +1,246 @@ +"""YOLO 检测:管理员配置的权重优先;无权重或加载失败则演示模式。""" +from __future__ import annotations + +import base64 +import logging +import random +from typing import Any + +import cv2 +import numpy as np + +from app.config import get_settings +from app.schemas.models import Detection +from app.services.yolo_manager import get_yolo_manager + +logger = logging.getLogger(__name__) + +_YOLO_OK = False +try: + from ultralytics import YOLO # type: ignore + + _YOLO_OK = True +except Exception: # pragma: no cover + YOLO = None # type: ignore + logger.info("ultralytics 未安装,影像检测将使用演示模式") + + +LABEL_ZH = { + # 演示 / 规则降级 + "opacity": "片状阴影/渗出", + "nodule": "结节", + "fracture": "骨折线", + "effusion": "积液", + "lesion": "异常 dens 区", + "calcification": "钙化", + "mass": "占位", + "object": "可疑区域", + # 肺炎单类权重 + "Pneumonia": "肺炎", + "pneumonia": "肺炎", + # 胸部 X 光多病灶检测(VinBigData / 类似类别) + "Aortic enlargement": "主动脉增宽", + "Atelectasis": "肺不张", + "Calcification": "钙化", + "Cardiomegaly": "心脏增大", + "Consolidation": "实变", + "ILD": "间质性肺病", + "Infiltration": "浸润", + "Lung Opacity": "肺野透过度减低", + "Nodule/Mass": "结节/肿块", + "Other lesion": "其他病灶", + "Pleural effusion": "胸腔积液", + "Pleural thickening": "胸膜增厚", + "Pneumothorax": "气胸", + "Pulmonary fibrosis": "肺纤维化", +} + + +def yolo_available() -> bool: + return _YOLO_OK + + +class YoloDetector: + def __init__(self) -> None: + self._model = None + self._mode: str = "demo" + self._loaded_path: str | None = None + self._class_names: dict[int, str] = {} + self._load_error: str | None = None + self.reload() + + def reload(self) -> dict[str, Any]: + """按管理端配置重新加载权重。""" + mgr = get_yolo_manager() + mode_pref = mgr.effective_demo_mode() + weights = mgr.active_weight_path() + self._model = None + self._class_names = {} + self._load_error = None + self._loaded_path = None + + if mode_pref == "demo": + self._mode = "demo" + logger.info("YOLO 强制演示模式") + return self.info() + + if weights is None: + self._mode = "demo" + if mode_pref == "real": + self._load_error = "已选 real 模式但未配置有效权重文件" + logger.warning(self._load_error) + else: + logger.info("未配置权重,使用演示模式") + return self.info() + + if not _YOLO_OK: + self._mode = "demo" + self._load_error = "未安装 ultralytics,无法加载真实权重" + logger.warning(self._load_error) + return self.info() + + try: + self._model = YOLO(str(weights)) + self._mode = "real" + self._loaded_path = str(weights) + names = getattr(self._model, "names", None) or {} + if isinstance(names, dict): + self._class_names = {int(k): str(v) for k, v in names.items()} + logger.info("已加载 YOLO 权重: %s", weights) + except Exception as e: + self._mode = "demo" + self._model = None + self._load_error = f"加载权重失败: {e}" + logger.warning(self._load_error) + return self.info() + + @property + def mode(self) -> str: + return self._mode + + def info(self) -> dict[str, Any]: + return { + "runtime_mode": self._mode, + "ultralytics_installed": _YOLO_OK, + "loaded_path": self._loaded_path, + "class_names": list(self._class_names.values()) if self._class_names else [], + "class_count": len(self._class_names), + "load_error": self._load_error, + "env_demo_mode": get_settings().demo_mode, + } + + def detect(self, image_bgr: np.ndarray, study_type: str = "CT") -> list[Detection]: + if self._mode == "real" and self._model is not None: + return self._detect_real(image_bgr) + return self._detect_demo(image_bgr, study_type) + + def _detect_real(self, image_bgr: np.ndarray) -> list[Detection]: + results = self._model.predict(source=image_bgr, verbose=False) + detections: list[Detection] = [] + if not results: + return detections + r0 = results[0] + names = r0.names or self._class_names or {} + boxes = getattr(r0, "boxes", None) + if boxes is None: + return detections + for box in boxes: + xyxy = box.xyxy[0].tolist() + conf = float(box.conf[0]) if box.conf is not None else 0.0 + cls_id = int(box.cls[0]) if box.cls is not None else 0 + label = str(names.get(cls_id, f"class_{cls_id}")) + detections.append( + Detection( + label=label, + label_zh=LABEL_ZH.get(label, label), + confidence=round(conf, 4), + bbox=[round(float(x), 2) for x in xyxy], + ) + ) + return detections + + def _detect_demo(self, image_bgr: np.ndarray, study_type: str) -> list[Detection]: + h, w = image_bgr.shape[:2] + rng = random.Random(h * 31 + w * 17 + hash(study_type) % 997) + catalog = { + "X_RAY": [("opacity", 0.86), ("fracture", 0.78)], + "CT": [("nodule", 0.88), ("lesion", 0.81), ("calcification", 0.74)], + "MRI": [("lesion", 0.84), ("mass", 0.79)], + "ULTRASOUND": [("mass", 0.80), ("lesion", 0.76)], + } + pairs = catalog.get(study_type.upper(), [("object", 0.75)]) + n = 1 if rng.random() < 0.35 else 2 + chosen = pairs[:n] if len(pairs) >= n else pairs + detections: list[Detection] = [] + for i, (label, base_conf) in enumerate(chosen): + bw = int(w * rng.uniform(0.12, 0.28)) + bh = int(h * rng.uniform(0.12, 0.28)) + x1 = int(rng.uniform(0.1, 0.65) * w) + y1 = int(rng.uniform(0.1, 0.65) * h) + x2 = min(w - 1, x1 + bw) + y2 = min(h - 1, y1 + bh) + conf = min(0.98, base_conf + rng.uniform(-0.05, 0.08) - i * 0.03) + detections.append( + Detection( + label=label, + label_zh=LABEL_ZH.get(label, label), + confidence=round(conf, 4), + bbox=[float(x1), float(y1), float(x2), float(y2)], + ) + ) + return detections + + def annotate(self, image_bgr: np.ndarray, detections: list[Detection]) -> str: + """返回 JPEG base64(无 data URL 前缀)。""" + canvas = image_bgr.copy() + for det in detections: + x1, y1, x2, y2 = [int(v) for v in det.bbox] + color = (40, 120, 255) if self._mode == "demo" else (46, 204, 113) + cv2.rectangle(canvas, (x1, y1), (x2, y2), color, 2) + text = f"{det.label_zh or det.label} {det.confidence:.0%}" + (tw, th), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.55, 1) + cv2.rectangle(canvas, (x1, max(0, y1 - th - 8)), (x1 + tw + 6, y1), color, -1) + cv2.putText( + canvas, + text, + (x1 + 3, y1 - 5), + cv2.FONT_HERSHEY_SIMPLEX, + 0.55, + (255, 255, 255), + 1, + cv2.LINE_AA, + ) + # 角标:模式 / 权重 + badge = f"YOLO:{self._mode}" + if self._loaded_path: + badge += f" | {PathName(self._loaded_path)}" + cv2.putText( + canvas, badge, (10, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (20, 20, 20), 3, cv2.LINE_AA + ) + cv2.putText( + canvas, badge, (10, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1, cv2.LINE_AA + ) + ok, buf = cv2.imencode(".jpg", canvas, [int(cv2.IMWRITE_JPEG_QUALITY), 88]) + if not ok: + raise RuntimeError("标注图编码失败") + return base64.b64encode(buf.tobytes()).decode("ascii") + + +def PathName(p: str) -> str: + from pathlib import Path + return Path(p).name + + +_detector: YoloDetector | None = None + + +def get_detector() -> YoloDetector: + global _detector + if _detector is None: + _detector = YoloDetector() + return _detector + + +def reload_detector() -> dict[str, Any]: + det = get_detector() + return det.reload() diff --git a/ai-service/app/services/yolo_manager.py b/ai-service/app/services/yolo_manager.py new file mode 100644 index 0000000..509a254 --- /dev/null +++ b/ai-service/app/services/yolo_manager.py @@ -0,0 +1,345 @@ +"""YOLO 权重文件管理:上传、激活、统计可视化数据。""" +from __future__ import annotations + +import json +import logging +import re +import shutil +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from app.config import get_settings + +logger = logging.getLogger(__name__) + +ALLOWED_EXT = {".pt", ".pth", ".onnx", ".engine"} + + +class YoloManager: + def __init__(self) -> None: + settings = get_settings() + self.root = settings.root_dir + self.weights_dir = (self.root / "data" / "weights").resolve() + self.config_path = (self.root / "data" / "yolo_config.json").resolve() + self.stats_path = (self.root / "data" / "yolo_stats.json").resolve() + self.weights_dir.mkdir(parents=True, exist_ok=True) + self.config_path.parent.mkdir(parents=True, exist_ok=True) + self._config = self._load_json(self.config_path, default={ + "active_weight": "", + "demo_mode": "auto", # demo | real | auto + "deleted_weights": [], # 逻辑删除的文件名列表(本地 .pt 仍保留) + "updated_at": None, + }) + if not isinstance(self._config.get("deleted_weights"), list): + self._config["deleted_weights"] = [] + self._stats = self._load_json(self.stats_path, default=self._empty_stats()) + + @staticmethod + def _empty_stats() -> dict[str, Any]: + return { + "total_inferences": 0, + "real_count": 0, + "demo_count": 0, + "class_counts": {}, + "confidence_buckets": {"0-50": 0, "50-70": 0, "70-85": 0, "85-100": 0}, + "recent": [], # last 20 runs + "updated_at": None, + } + + @staticmethod + def _load_json(path: Path, default: dict) -> dict: + if not path.is_file(): + return dict(default) + try: + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, dict): + merged = dict(default) + merged.update(data) + return merged + except Exception as e: + logger.warning("读取 %s 失败: %s", path, e) + return dict(default) + + def _save_config(self) -> None: + self._config["updated_at"] = datetime.now(timezone.utc).isoformat() + self.config_path.write_text( + json.dumps(self._config, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + def _save_stats(self) -> None: + self._stats["updated_at"] = datetime.now(timezone.utc).isoformat() + self.stats_path.write_text( + json.dumps(self._stats, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + def _deleted_set(self) -> set[str]: + return {str(x) for x in (self._config.get("deleted_weights") or []) if x} + + def list_weights(self) -> list[dict[str, Any]]: + """仅列出未逻辑删除的权重。""" + active = (self._config.get("active_weight") or "").strip() + deleted = self._deleted_set() + items: list[dict[str, Any]] = [] + for p in sorted(self.weights_dir.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): + if not p.is_file() or p.suffix.lower() not in ALLOWED_EXT: + continue + if p.name in deleted: + continue + # 回收目录 / 隐藏文件不展示 + if p.name.startswith("."): + continue + st = p.stat() + items.append({ + "name": p.name, + "path": str(p), + "size_bytes": st.st_size, + "size_mb": round(st.st_size / (1024 * 1024), 3), + "modified_at": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).isoformat(), + "active": p.name == active, + "ext": p.suffix.lower(), + "deleted": False, + }) + return items + + def save_upload(self, filename: str, content: bytes) -> dict[str, Any]: + if not content: + raise ValueError("文件为空") + safe = self._safe_name(filename) + ext = Path(safe).suffix.lower() + if ext not in ALLOWED_EXT: + raise ValueError(f"仅支持权重格式: {', '.join(sorted(ALLOWED_EXT))}") + # 限制 500MB + if len(content) > 500 * 1024 * 1024: + raise ValueError("权重文件不能超过 500MB") + target = self.weights_dir / safe + # 避免覆盖:同名追加时间戳 + if target.exists(): + stem = target.stem + target = self.weights_dir / f"{stem}_{int(time.time())}{ext}" + safe = target.name + target.write_bytes(content) + logger.info("已保存 YOLO 权重: %s (%d bytes)", target, len(content)) + return { + "name": safe, + "path": str(target), + "size_bytes": len(content), + "size_mb": round(len(content) / (1024 * 1024), 3), + "active": False, + } + + def activate(self, name: str, demo_mode: str | None = None) -> dict[str, Any]: + path = self.weights_dir / name + if not path.is_file(): + raise FileNotFoundError(f"权重不存在: {name}") + if name in self._deleted_set(): + raise FileNotFoundError(f"权重已逻辑删除,无法激活: {name}") + if path.suffix.lower() not in ALLOWED_EXT: + raise ValueError("非法权重文件") + self._config["active_weight"] = name + if demo_mode in ("demo", "real", "auto"): + self._config["demo_mode"] = demo_mode + elif not self._config.get("demo_mode"): + self._config["demo_mode"] = "auto" + self._save_config() + return self.status() + + def set_demo_mode(self, mode: str) -> dict[str, Any]: + if mode not in ("demo", "real", "auto"): + raise ValueError("demo_mode 仅支持 demo / real / auto") + self._config["demo_mode"] = mode + self._save_config() + return self.status() + + def deactivate(self) -> dict[str, Any]: + self._config["active_weight"] = "" + self._save_config() + return self.status() + + def delete_weight(self, name: str) -> None: + """逻辑删除:不删除磁盘文件,仅从可用列表隐藏。""" + path = self.weights_dir / name + if not path.is_file() and name not in self._deleted_set(): + raise FileNotFoundError(f"权重不存在: {name}") + if path.is_file() and path.suffix.lower() not in ALLOWED_EXT: + raise ValueError("非法权重文件") + deleted = list(self._config.get("deleted_weights") or []) + if name not in deleted: + deleted.append(name) + self._config["deleted_weights"] = deleted + if (self._config.get("active_weight") or "") == name: + self._config["active_weight"] = "" + self._save_config() + logger.info("YOLO 权重逻辑删除(文件保留): %s path=%s", name, path) + + def restore_weight(self, name: str) -> dict[str, Any]: + """从逻辑删除中恢复(若本地文件仍在)。""" + path = self.weights_dir / name + if not path.is_file(): + raise FileNotFoundError(f"本地文件不存在,无法恢复: {name}") + deleted = [x for x in (self._config.get("deleted_weights") or []) if x != name] + self._config["deleted_weights"] = deleted + self._save_config() + logger.info("YOLO 权重已从逻辑删除恢复: %s", name) + return self.status() + + def active_weight_path(self) -> Path | None: + name = (self._config.get("active_weight") or "").strip() + if not name: + # 兼容环境变量 + settings = get_settings() + return settings.yolo_weights_path + if name in self._deleted_set(): + return None + path = self.weights_dir / name + return path if path.is_file() else None + + def effective_demo_mode(self) -> str: + mode = (self._config.get("demo_mode") or "auto").strip().lower() + if mode in ("demo", "real", "auto"): + return mode + return get_settings().demo_mode + + def status(self, detector_info: dict[str, Any] | None = None) -> dict[str, Any]: + active_path = self.active_weight_path() + weights = self.list_weights() + body: dict[str, Any] = { + "weights_dir": str(self.weights_dir), + "active_weight": self._config.get("active_weight") or "", + "active_path": str(active_path) if active_path else None, + "active_exists": active_path is not None and active_path.is_file(), + "demo_mode": self.effective_demo_mode(), + "weights_count": len(weights), + "weights": weights, + "updated_at": self._config.get("updated_at"), + } + if detector_info: + body.update(detector_info) + return body + + def record_inference( + self, + mode: str, + detections: list[Any], + study_type: str = "", + model_version: str = "", + ) -> None: + self._stats["total_inferences"] = int(self._stats.get("total_inferences") or 0) + 1 + if mode == "real": + self._stats["real_count"] = int(self._stats.get("real_count") or 0) + 1 + else: + self._stats["demo_count"] = int(self._stats.get("demo_count") or 0) + 1 + + class_counts: dict[str, int] = self._stats.setdefault("class_counts", {}) + buckets: dict[str, int] = self._stats.setdefault( + "confidence_buckets", + {"0-50": 0, "50-70": 0, "70-85": 0, "85-100": 0}, + ) + labels: list[str] = [] + confs: list[float] = [] + for d in detections or []: + if hasattr(d, "label"): + label = getattr(d, "label_zh", None) or d.label + conf = float(d.confidence) + elif isinstance(d, dict): + label = d.get("label_zh") or d.get("label") or "unknown" + conf = float(d.get("confidence") or 0) + else: + continue + labels.append(str(label)) + confs.append(conf) + class_counts[str(label)] = int(class_counts.get(str(label), 0)) + 1 + pct = conf * 100 + if pct < 50: + buckets["0-50"] = buckets.get("0-50", 0) + 1 + elif pct < 70: + buckets["50-70"] = buckets.get("50-70", 0) + 1 + elif pct < 85: + buckets["70-85"] = buckets.get("70-85", 0) + 1 + else: + buckets["85-100"] = buckets.get("85-100", 0) + 1 + + recent = self._stats.setdefault("recent", []) + recent.insert(0, { + "time": datetime.now(timezone.utc).isoformat(), + "mode": mode, + "study_type": study_type, + "model_version": model_version, + "detection_count": len(labels), + "labels": labels[:10], + "avg_confidence": round(sum(confs) / len(confs), 4) if confs else 0, + "weight": self._config.get("active_weight") or "", + }) + self._stats["recent"] = recent[:30] + self._save_stats() + + def visualization(self) -> dict[str, Any]: + """供前端 ECharts 使用的聚合数据。""" + class_counts = self._stats.get("class_counts") or {} + buckets = self._stats.get("confidence_buckets") or {} + real = int(self._stats.get("real_count") or 0) + demo = int(self._stats.get("demo_count") or 0) + total = int(self._stats.get("total_inferences") or 0) + class_pie = [ + {"name": k, "value": v} + for k, v in sorted(class_counts.items(), key=lambda x: -x[1]) + ] + conf_bar = [ + {"name": k, "value": int(buckets.get(k, 0))} + for k in ["0-50", "50-70", "70-85", "85-100"] + ] + mode_pie = [ + {"name": "真实权重推理", "value": real}, + {"name": "演示模式", "value": demo}, + ] + # 近 10 次检测数折线 + recent = list(reversed(self._stats.get("recent") or []))[-15:] + trend = { + "times": [ (r.get("time") or "")[11:19] for r in recent ], + "counts": [ int(r.get("detection_count") or 0) for r in recent ], + "modes": [ r.get("mode") or "" for r in recent ], + } + active = self.active_weight_path() + return { + "summary": { + "total_inferences": total, + "real_count": real, + "demo_count": demo, + "real_ratio": round(real / total, 4) if total else 0, + "active_weight": self._config.get("active_weight") or "", + "demo_mode": self.effective_demo_mode(), + "active_exists": bool(active and active.is_file()), + "weights_count": len(self.list_weights()), + }, + "class_distribution": class_pie, + "confidence_distribution": conf_bar, + "mode_distribution": mode_pie, + "inference_trend": trend, + "recent": self._stats.get("recent") or [], + "updated_at": self._stats.get("updated_at"), + } + + def reset_stats(self) -> None: + self._stats = self._empty_stats() + self._save_stats() + + @staticmethod + def _safe_name(filename: str) -> str: + name = Path(filename or "weights.pt").name + name = re.sub(r"[^\w.\-()+]", "_", name) + if not name or name in (".", ".."): + name = f"weights_{int(time.time())}.pt" + return name + + +_manager: YoloManager | None = None + + +def get_yolo_manager() -> YoloManager: + global _manager + if _manager is None: + _manager = YoloManager() + return _manager diff --git a/ai-service/requirements.txt b/ai-service/requirements.txt new file mode 100644 index 0000000..f76f4e6 --- /dev/null +++ b/ai-service/requirements.txt @@ -0,0 +1,23 @@ +# 智慧医院 AI 微服务(演示级) +fastapi>=0.110.0 +uvicorn[standard]>=0.27.0 +pydantic>=2.6.0 +pydantic-settings>=2.2.0 +python-multipart>=0.0.9 +httpx>=0.27.0 +numpy>=1.26.0 +Pillow>=10.2.0 +# 无 GUI 的 OpenCV,比 opencv-python 更小 +opencv-python-headless>=4.9.0 + +# LangChain RAG(兼容 OpenAI 协议:DeepSeek / Qwen) +langchain>=0.2.0 +langchain-core>=0.2.0 +langchain-community>=0.2.0 +langchain-text-splitters>=0.2.0 + +# 视觉推理:请先单独安装 CPU 版 torch,再装 ultralytics +# pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu +# pip install ultralytics +# monai 可选:pip install monai +ultralytics diff --git a/ai-service/samples/README.md b/ai-service/samples/README.md new file mode 100644 index 0000000..9b2f98f --- /dev/null +++ b/ai-service/samples/README.md @@ -0,0 +1,142 @@ +# 测试素材说明(图片集 + YOLO 权重) + +本目录与 `data/weights` 已预置**可离线手动测试**的公开样例,供实训演示使用。 + +> ⚠️ 仅供教学 / 系统联调,**不能用于真实临床诊断**。 + +--- + +## 一、YOLO 权重位置 + +目录: + +``` +ai-service/data/weights/ +├── yolov8n.pt # YOLOv8 Nano 检测(约 6.3 MB,推荐先用这个) +└── yolov8n-seg.pt # YOLOv8 Nano 分割(约 6.7 MB,可选) +``` + +| 文件 | 来源 | 适配说明 | +|------|------|----------| +| `yolov8n.pt` | Ultralytics 官方 COCO 预训练 | 与本系统 FastAPI `ultralytics.YOLO` 完全兼容;可在「YOLO 权重」页上传/激活 | +| `yolov8n-seg.pt` | 官方分割权重 | 本系统当前检测管线以 **detect** 为主,分割权重可作扩展试验 | + +**说明:** +公开可直接下载、体积适中、协议清晰的医学专用 YOLO 权重较少;此处采用官方通用权重验证「上传 → 激活 → 真实推理」全链路。 +医学影像上 COCO 类别不一定命中(可能 0 个框),属正常;此时系统仍可走报告逻辑,或回退演示检测。 +若你有自己训练的胸片/结节 `.pt`,直接替换或上传覆盖即可。 + +### 在管理端激活 + +1. 登录 `admin / admin123` +2. 打开 **YOLO 权重** +3. 上传 `ai-service/data/weights/yolov8n.pt`(或直接在该目录已有文件时点刷新后激活) +4. 策略选 `auto` 或 `real` +5. **需安装** `ultralytics` 才能真实加载: + +```bash +cd ai-service +.\.venv\Scripts\activate +pip install ultralytics torch torchvision +# 重启 AI 服务 +uvicorn app.main:app --host 0.0.0.0 --port 8001 +``` + +也可在 PowerShell 用接口激活(AI 服务已启动时): + +```bash +# 若权重已在 data/weights 下,FastAPI 可直接 activate +curl -X POST http://127.0.0.1:8001/yolo/weights/activate -H "Content-Type: application/json" -d "{\"name\":\"yolov8n.pt\",\"demo_mode\":\"auto\"}" +``` + +--- + +## 二、图片集位置 + +### 1. 真实公开胸片(COVID 相关科研数据集子集) + +``` +ai-service/samples/medical/ +├── chest_sample_1.jpeg ~ chest_sample_4.jpeg +├── covid_xray_1.jpg ~ covid_xray_3.jpg +``` + +- 来源:[ieee8023/covid-chestxray-dataset](https://github.com/ieee8023/covid-chestxray-dataset)(GitHub 公开) +- 建议在前端 **影像诊断** 中类型选 **X_RAY / CT**,部位填「胸部」后上传测试 + +### 2. 仿真医学图(本地生成,联调兜底) + +``` +sim_chest_xray_normal.png +sim_chest_xray_opacity.png +sim_chest_ct_nodule.png +sim_brain_ct.png +sim_knee_xray.png +sim_ultrasound.png +``` + +灰度仿真图,便于无外网时继续测 UI/报告链路。 + +### 3. 通用目标检测验证图(验证权重是否“真的在跑”) + +``` +ai-service/samples/bus.jpg +ai-service/samples/zidane.jpg +``` + +- 来源:Ultralytics / YOLOv5 示例图 +- 用 `yolov8n.pt` 推理时应能检出 **person / bus** 等 COCO 类别 +- 用于确认:权重激活成功 + ultralytics 安装正确 + +### 4. 业务端副本(方便对照) + +已复制一份医学样例到: + +``` +smart-hospital/uploads/images/samples/ +``` + +--- + +## 三、推荐手动测试流程 + +### A. 验证「真实权重」链路 + +1. 安装 `ultralytics` 并重启 AI 服务 +2. 管理端激活 `yolov8n.pt`,模式 `auto`/`real` +3. 上传 `samples/bus.jpg` 到影像诊断并 AI 诊断 +4. 查看报告:应出现检测框;引擎显示 `real` +5. 回到 **YOLO 权重** 页看可视化统计是否增加「真实权重推理」 + +### B. 验证「医学影像」业务链路 + +1. 上传 `samples/medical/covid_xray_1.jpg` 等 +2. 类型选 X_RAY,部位「胸部」 +3. 看标注图 + 诊断报告 + 置信度 +4. (可选)无权重或 demo 模式下会出演示框,属预期 + +### C. 仅演示模式(不装 torch) + +1. 不激活权重,或模式选 `demo` +2. 任意医学图均可出演示检测框与报告 + +--- + +## 四、目录速查 + +| 内容 | 路径 | +|------|------| +| 权重 | `ai-service/data/weights/` | +| 医学图片 | `ai-service/samples/medical/` | +| 通用测试图 | `ai-service/samples/bus.jpg` 等 | +| 权重配置(激活后生成) | `ai-service/data/yolo_config.json` | +| 推理统计(诊断后生成) | `ai-service/data/yolo_stats.json` | + +--- + +## 五、许可与合规 + +- Ultralytics 权重:遵循 Ultralytics / AGPL 相关协议,仅作学习研究 +- covid-chestxray-dataset:遵循原仓库许可证,仅作科研/教学 +- 仿真图:项目内生成,无患者隐私 +- **禁止将本素材与系统输出用于真实诊疗** diff --git a/data/ai/chat-history.json b/data/ai/chat-history.json new file mode 100644 index 0000000..8878e54 --- /dev/null +++ b/data/ai/chat-history.json @@ -0,0 +1 @@ +[ ] \ No newline at end of file diff --git a/data/ai/settings.json b/data/ai/settings.json new file mode 100644 index 0000000..d4e90e0 --- /dev/null +++ b/data/ai/settings.json @@ -0,0 +1,10 @@ +{ + "id" : 1, + "apiBaseUrl" : "https://api.openai.com", + "apiKey" : null, + "model" : "gpt-4o-mini", + "enabled" : false, + "temperature" : 0.7, + "systemPrompt" : "你是智慧医院的专业医疗助手。回答需严谨、通俗,涉及诊断与用药时请提醒最终以执业医师意见为准。", + "updatedAt" : "2026-07-23T15:50:24.9484851" +} \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..1b7758a --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.env.local +.vite diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c90fb53 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + 智慧医院 AI 影像诊断与电子病历辅助决策系统 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..d621df6 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2674 @@ +{ + "name": "smart-hospital-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "smart-hospital-frontend", + "version": "1.0.0", + "dependencies": { + "axios": "1.7.7", + "dompurify": "^3.4.12", + "echarts": "5.5.1", + "element-plus": "2.8.4", + "marked": "^18.0.7", + "pinia": "2.2.4", + "vue": "3.5.10", + "vue-echarts": "7.0.3", + "vue-router": "4.4.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "5.1.4", + "unplugin-auto-import": "0.18.3", + "unplugin-vue-components": "0.27.4", + "vite": "5.4.8" + } + }, + "node_modules/@antfu/utils": { + "version": "0.7.10", + "resolved": "https://registry.npmmirror.com/@antfu/utils/-/utils-0.7.10.tgz", + "integrity": "sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.16", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz", + "integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.1.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.1.4.tgz", + "integrity": "sha512-N2XSI2n3sQqp5w7Y/AN/L2XDjBIRGqXko+eDp42sydYSBeJuSm5a1sLf8zakmo8u7tA8NmBgoDLA1HeOESjp9A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.10", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.10.tgz", + "integrity": "sha512-iXWlk+Cg/ag7gLvY0SfVucU8Kh2CjysYZjhhP70w9qI4MvSox4frrP+vDGvtQuzIcgD8+sxM6lZvCtdxGunTAA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/shared": "3.5.10", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.10", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.10.tgz", + "integrity": "sha512-DyxHC6qPcktwYGKOIy3XqnHRrrXyWR2u91AjP+nLkADko380srsC2DC3s7Y1Rk6YfOlxOlvEQKa9XXmLI+W4ZA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.10", + "@vue/shared": "3.5.10" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.10", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.10.tgz", + "integrity": "sha512-to8E1BgpakV7224ZCm8gz1ZRSyjNCAWEplwFMWKlzCdP9DkMKhRRwt0WkCjY7jkzi/Vz3xgbpeig5Pnbly4Tow==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/compiler-core": "3.5.10", + "@vue/compiler-dom": "3.5.10", + "@vue/compiler-ssr": "3.5.10", + "@vue/shared": "3.5.10", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.11", + "postcss": "^8.4.47", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.10", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.10.tgz", + "integrity": "sha512-hxP4Y3KImqdtyUKXDRSxKSRkSm1H9fCvhojEYrnaoWhE4w/y8vwWhnosJoPPe2AXm5sU7CSbYYAgkt2ZPhDz+A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.10", + "@vue/shared": "3.5.10" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.10", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.10.tgz", + "integrity": "sha512-kW08v06F6xPSHhid9DJ9YjOGmwNDOsJJQk0ax21wKaUYzzuJGEuoKNU2Ujux8FLMrP7CFJJKsHhXN9l2WOVi2g==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.10" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.10", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.10.tgz", + "integrity": "sha512-9Q86I5Qq3swSkFfzrZ+iqEy7Vla325M7S7xc1NwKnRm/qoi1Dauz0rT6mTMmscqx4qz0EDJ1wjB+A36k7rl8mA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.10", + "@vue/shared": "3.5.10" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.10", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.10.tgz", + "integrity": "sha512-t3x7ht5qF8ZRi1H4fZqFzyY2j+GTMTDxRheT+i8M9Ph0oepUxoadmbwlFwMoW7RYCpNQLpP2Yx3feKs+fyBdpA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.10", + "@vue/runtime-core": "3.5.10", + "@vue/shared": "3.5.10", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.10", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.10.tgz", + "integrity": "sha512-IVE97tt2kGKwHNq9yVO0xdh1IvYfZCShvDSy46JIh5OQxP1/EXSpoDqetVmyIzL7CYOWnnmMkVqd7YK2QSWkdw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.10", + "@vue/shared": "3.5.10" + }, + "peerDependencies": { + "vue": "3.5.10" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.10", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.10.tgz", + "integrity": "sha512-VkkBhU97Ki+XJ0xvl4C9YJsIZ2uIlQ7HqPpZOS3m9VCvmROPaChZU6DexdMJqvz9tbgG+4EtFVrSuailUq5KGQ==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "9.13.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-9.13.0.tgz", + "integrity": "sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.16", + "@vueuse/metadata": "9.13.0", + "@vueuse/shared": "9.13.0", + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "9.13.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-9.13.0.tgz", + "integrity": "sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "9.13.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-9.13.0.tgz", + "integrity": "sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==", + "license": "MIT", + "dependencies": { + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.5.1", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.5.1.tgz", + "integrity": "sha512-Fce8upazaAXUVUVsjgV6mBnGuqgO+JNDlcgF79Dksy4+wgGpQB2lmYoO4TSweFg/mZITdpGHomw/cNBJZj1icA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.0" + } + }, + "node_modules/element-plus": { + "version": "2.8.4", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.8.4.tgz", + "integrity": "sha512-ZlVAdUOoJliv4kW3ntWnnSHMT+u/Os7mXJjk2xzOlqNeHaI2/ozlF+R58ZCEak8ZnDi6+5A2viWEYRsq64IuiA==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.4.1", + "@element-plus/icons-vue": "^2.3.1", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.14.182", + "@types/lodash-es": "^4.17.6", + "@vueuse/core": "^9.1.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.3", + "escape-html": "^1.0.3", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "lodash-unified": "^1.0.2", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.1.0.tgz", + "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "18.0.7", + "resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.7.tgz", + "integrity": "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.2.4", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.2.4.tgz", + "integrity": "sha512-K7ZhpMY9iJ9ShTC0cR2+PnxdQRuwVIsXDO/WIEV/RnMC/vmSoKDTKW/exNQYPI+4ij10UjXqdNiEHwn47McANQ==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@vue/composition-api": "^1.4.0", + "typescript": ">=4.4.4", + "vue": "^2.6.14 || ^3.3.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.22", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unimport": { + "version": "3.14.6", + "resolved": "https://registry.npmmirror.com/unimport/-/unimport-3.14.6.tgz", + "integrity": "sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.4", + "acorn": "^8.14.0", + "escape-string-regexp": "^5.0.0", + "estree-walker": "^3.0.3", + "fast-glob": "^3.3.3", + "local-pkg": "^1.0.0", + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "pathe": "^2.0.1", + "picomatch": "^4.0.2", + "pkg-types": "^1.3.0", + "scule": "^1.3.0", + "strip-literal": "^2.1.1", + "unplugin": "^1.16.1" + } + }, + "node_modules/unimport/node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unimport/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/unimport/node_modules/local-pkg": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/unimport/node_modules/local-pkg/node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/unplugin": { + "version": "1.16.1", + "resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-1.16.1.tgz", + "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.14.0", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/unplugin-auto-import": { + "version": "0.18.3", + "resolved": "https://registry.npmmirror.com/unplugin-auto-import/-/unplugin-auto-import-0.18.3.tgz", + "integrity": "sha512-q3FUtGQjYA2e+kb1WumyiQMjHM27MrTQ05QfVwtLRVhyYe+KF6TblBYaEX9L6Z0EibsqaXAiW+RFfkcQpfaXzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/utils": "^0.7.10", + "@rollup/pluginutils": "^5.1.0", + "fast-glob": "^3.3.2", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.11", + "minimatch": "^9.0.5", + "unimport": "^3.12.0", + "unplugin": "^1.14.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2", + "@vueuse/core": "*" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@vueuse/core": { + "optional": true + } + } + }, + "node_modules/unplugin-vue-components": { + "version": "0.27.4", + "resolved": "https://registry.npmmirror.com/unplugin-vue-components/-/unplugin-vue-components-0.27.4.tgz", + "integrity": "sha512-1XVl5iXG7P1UrOMnaj2ogYa5YTq8aoh5jwDPQhemwO/OrXW+lPQKDXd1hMz15qxQPxgb/XXlbgo3HQ2rLEbmXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/utils": "^0.7.10", + "@rollup/pluginutils": "^5.1.0", + "chokidar": "^3.6.0", + "debug": "^4.3.6", + "fast-glob": "^3.3.2", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.11", + "minimatch": "^9.0.5", + "mlly": "^1.7.1", + "unplugin": "^1.12.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@babel/parser": "^7.15.8", + "@nuxt/kit": "^3.2.2", + "vue": "2 || 3" + }, + "peerDependenciesMeta": { + "@babel/parser": { + "optional": true + }, + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "5.4.8", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.8.tgz", + "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.10", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.10.tgz", + "integrity": "sha512-Vy2kmJwHPlouC/tSnIgXVg03SG+9wSqT1xu1Vehc+ChsXsRd7jLkKgMltVEFOzUdBr3uFwBCG+41LJtfAcBRng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.10", + "@vue/compiler-sfc": "3.5.10", + "@vue/runtime-dom": "3.5.10", + "@vue/server-renderer": "3.5.10", + "@vue/shared": "3.5.10" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-echarts": { + "version": "7.0.3", + "resolved": "https://registry.npmmirror.com/vue-echarts/-/vue-echarts-7.0.3.tgz", + "integrity": "sha512-/jSxNwOsw5+dYAUcwSfkLwKPuzTQ0Cepz1LxCOpj2QcHrrmUa/Ql0eQqMmc1rTPQVrh2JQ29n2dhq75ZcHvRDw==", + "license": "MIT", + "dependencies": { + "vue-demi": "^0.13.11" + }, + "peerDependencies": { + "@vue/runtime-core": "^3.0.0", + "echarts": "^5.5.1", + "vue": "^2.7.0 || ^3.1.1" + }, + "peerDependenciesMeta": { + "@vue/runtime-core": { + "optional": true + } + } + }, + "node_modules/vue-echarts/node_modules/vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.4.5", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.4.5.tgz", + "integrity": "sha512-4fKZygS8cH1yCyuabAXGUAsyi1b2/o/OKgu/RUb+znIYOxPRxdkytJEx+0wGcpBE1pX6vUgh5jwWOKRGvuA/7Q==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/zrender": { + "version": "5.6.0", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.0.tgz", + "integrity": "sha512-uzgraf4njmmHAbEUxMJ8Oxg+P3fT04O+9p7gY+wJRVxo8Ge+KmYv0WJev945EH4wFuc4OY2NLXz46FZrWS9xJg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5aaddbd --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "smart-hospital-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "1.7.7", + "dompurify": "^3.4.12", + "echarts": "5.5.1", + "element-plus": "2.8.4", + "marked": "^18.0.7", + "pinia": "2.2.4", + "vue": "3.5.10", + "vue-echarts": "7.0.3", + "vue-router": "4.4.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "5.1.4", + "unplugin-auto-import": "0.18.3", + "unplugin-vue-components": "0.27.4", + "vite": "5.4.8" + } +} diff --git a/frontend/public/images/aside-bg.jpg b/frontend/public/images/aside-bg.jpg new file mode 100644 index 0000000..cefcf70 Binary files /dev/null and b/frontend/public/images/aside-bg.jpg differ diff --git a/frontend/public/images/login-bg.jpg b/frontend/public/images/login-bg.jpg new file mode 100644 index 0000000..de30e32 Binary files /dev/null and b/frontend/public/images/login-bg.jpg differ diff --git a/frontend/public/images/main-bg.jpg b/frontend/public/images/main-bg.jpg new file mode 100644 index 0000000..e24ef20 Binary files /dev/null and b/frontend/public/images/main-bg.jpg differ diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..0904c67 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/frontend/src/api/ai.js b/frontend/src/api/ai.js new file mode 100644 index 0000000..60dd2ad --- /dev/null +++ b/frontend/src/api/ai.js @@ -0,0 +1,41 @@ +import request from '@/utils/request' + +// —— 管理端:AI 配置 —— +export const getAiSettings = () => request.get('/admin/ai-settings') +export const saveAiSettings = (data) => request.put('/admin/ai-settings', data) +export const testAiSettings = () => request.post('/admin/ai-settings/test', null, { timeout: 90000 }) + +// —— AI 微服务状态(所有登录用户)—— +export const getAiServiceStatus = () => request.get('/admin/ai-service/status') + +// —— 管理端:知识库 —— +export const listKnowledge = (params) => request.get('/admin/ai-knowledge', { params }) +export const getKnowledge = (id) => request.get(`/admin/ai-knowledge/${id}`) +export const createKnowledge = (data) => request.post('/admin/ai-knowledge', data) +export const updateKnowledge = (id, data) => request.put(`/admin/ai-knowledge/${id}`, data) +export const setKnowledgeEnabled = (id, enabled) => + request.patch(`/admin/ai-knowledge/${id}/enabled`, { enabled }) +export const deleteKnowledge = (id) => request.delete(`/admin/ai-knowledge/${id}`) + +// —— 业务端:对话 —— +export const aiChat = (data) => request.post('/ai/chat', data, { timeout: 120000 }) +export const aiChatHistory = (params) => request.get('/ai/chat/history', { params }) +export const clearAiChatHistory = () => request.delete('/ai/chat/history') + +// —— 管理端:YOLO 权重 —— +export const getYoloStatus = () => request.get('/admin/yolo/status') +export const listYoloWeights = () => request.get('/admin/yolo/weights') +export const uploadYoloWeight = (file) => { + const fd = new FormData() + fd.append('file', file) + return request.post('/admin/yolo/weights/upload', fd, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 300000 + }) +} +export const activateYoloWeight = (data) => request.post('/admin/yolo/weights/activate', data) +export const deactivateYoloWeight = () => request.post('/admin/yolo/weights/deactivate') +export const deleteYoloWeight = (name) => request.delete(`/admin/yolo/weights/${encodeURIComponent(name)}`) +export const setYoloMode = (demoMode) => request.post('/admin/yolo/mode', { demo_mode: demoMode }) +export const getYoloVisualization = () => request.get('/admin/yolo/visualization') +export const resetYoloStats = () => request.post('/admin/yolo/stats/reset') diff --git a/frontend/src/api/appointment.js b/frontend/src/api/appointment.js new file mode 100644 index 0000000..51b7ed4 --- /dev/null +++ b/frontend/src/api/appointment.js @@ -0,0 +1,11 @@ +import request from '@/utils/request' + +export const listAppointments = (params) => request.get('/appointments', { params }) +export const getAppointment = (id) => request.get(`/appointments/${id}`) +export const getAppointmentStats = () => request.get('/appointments/stats') +export const listAppointmentDoctors = () => request.get('/appointments/doctors') +export const createAppointment = (data) => request.post('/appointments', data) +export const updateAppointment = (id, data) => request.put(`/appointments/${id}`, data) +export const updateAppointmentStatus = (id, status) => + request.patch(`/appointments/${id}/status`, { status }) +export const deleteAppointment = (id) => request.delete(`/appointments/${id}`) diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js new file mode 100644 index 0000000..3324702 --- /dev/null +++ b/frontend/src/api/auth.js @@ -0,0 +1,13 @@ +import request from '@/utils/request' + +export const login = (data) => request.post('/auth/login', data) +export const getMe = () => request.get('/auth/me') +export const logout = () => request.post('/auth/logout') +export const changePassword = (data) => request.post('/auth/change-password', data) +export const uploadAvatar = (file) => { + const form = new FormData() + form.append('file', file) + return request.post('/auth/avatar', form, { + headers: { 'Content-Type': 'multipart/form-data' } + }) +} diff --git a/frontend/src/api/emr.js b/frontend/src/api/emr.js new file mode 100644 index 0000000..2e04adc --- /dev/null +++ b/frontend/src/api/emr.js @@ -0,0 +1,9 @@ +import request from '@/utils/request' + +export const listEmrs = (params) => request.get('/emrs', { params }) +export const getEmr = (id) => request.get(`/emrs/${id}`) +export const createEmr = (data) => request.post('/emrs', data) +export const updateEmr = (id, data) => request.put(`/emrs/${id}`, data) +export const deleteEmr = (id) => request.delete(`/emrs/${id}`) +export const getAiSuggestions = (id) => request.get(`/emrs/${id}/ai-suggestions`) +export const aiSuggestions = getAiSuggestions diff --git a/frontend/src/api/imaging.js b/frontend/src/api/imaging.js new file mode 100644 index 0000000..dbd0de7 --- /dev/null +++ b/frontend/src/api/imaging.js @@ -0,0 +1,10 @@ +import request from '@/utils/request' + +export const listImaging = (params) => request.get('/imaging', { params }) +export const getImaging = (id) => request.get(`/imaging/${id}`) +export const createImaging = (data) => request.post('/imaging', data) +export const updateImaging = (id, data) => request.put(`/imaging/${id}`, data) +export const deleteImaging = (id) => request.delete(`/imaging/${id}`) +export const analyzeImaging = (id) => request.post(`/ai-diagnosis/analyze/${id}`) +export const getImagingResult = (id) => request.get(`/ai-diagnosis/result/${id}`) +export const getDiagnosisResult = getImagingResult diff --git a/frontend/src/api/patient.js b/frontend/src/api/patient.js new file mode 100644 index 0000000..b256390 --- /dev/null +++ b/frontend/src/api/patient.js @@ -0,0 +1,8 @@ +import request from '@/utils/request' + +export const listPatients = (params) => request.get('/patients', { params }) +export const getPatient = (id) => request.get(`/patients/${id}`) +export const getPatientProfile = (id) => request.get(`/patients/${id}/profile`) +export const createPatient = (data) => request.post('/patients', data) +export const updatePatient = (id, data) => request.put(`/patients/${id}`, data) +export const deletePatient = (id) => request.delete(`/patients/${id}`) diff --git a/frontend/src/api/stats.js b/frontend/src/api/stats.js new file mode 100644 index 0000000..204deaf --- /dev/null +++ b/frontend/src/api/stats.js @@ -0,0 +1,4 @@ +import request from '@/utils/request' + +export const getOverview = () => request.get('/stats/overview') +export const fetchOverview = getOverview diff --git a/frontend/src/api/user.js b/frontend/src/api/user.js new file mode 100644 index 0000000..b05c9c2 --- /dev/null +++ b/frontend/src/api/user.js @@ -0,0 +1,16 @@ +import request from '@/utils/request' + +export const listUsers = (params) => request.get('/users', { params }) +export const getUserStats = () => request.get('/users/stats') +export const createUser = (data) => request.post('/users', data) +export const updateUser = (id, data) => request.put(`/users/${id}`, data) +export const deleteUser = (id) => request.delete(`/users/${id}`) +export const setUserEnabled = (id, enabled) => + request.patch(`/users/${id}/enabled`, { enabled }) +export const uploadUserAvatar = (id, file) => { + const form = new FormData() + form.append('file', file) + return request.post(`/users/${id}/avatar`, form, { + headers: { 'Content-Type': 'multipart/form-data' } + }) +} diff --git a/frontend/src/layouts/BasicLayout.vue b/frontend/src/layouts/BasicLayout.vue new file mode 100644 index 0000000..8378e93 --- /dev/null +++ b/frontend/src/layouts/BasicLayout.vue @@ -0,0 +1,626 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..4f57c49 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,14 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import zhCn from 'element-plus/es/locale/lang/zh-cn' +import 'element-plus/dist/index.css' + +import App from './App.vue' +import router from './router' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { locale: zhCn }) +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..8c204b6 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,108 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useUserStore } from '@/stores/user' + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/Login.vue'), + meta: { public: true } + }, + { + path: '/', + component: () => import('@/layouts/BasicLayout.vue'), + redirect: '/dashboard', + children: [ + { + path: 'dashboard', + name: 'Dashboard', + component: () => import('@/views/Dashboard.vue'), + meta: { title: '仪表盘' } + }, + { + path: 'patients', + name: 'Patients', + component: () => import('@/views/Patients.vue'), + meta: { title: '患者管理' } + }, + { + path: 'imaging', + name: 'Imaging', + component: () => import('@/views/Imaging.vue'), + meta: { title: '影像诊断' } + }, + { + path: 'emrs', + name: 'Emrs', + component: () => import('@/views/Emrs.vue'), + meta: { title: '电子病历' } + }, + { + path: 'appointments', + name: 'Appointments', + component: () => import('@/views/Appointments.vue'), + meta: { title: '预约挂号' } + }, + { + path: 'ai-assistant', + name: 'AiAssistant', + component: () => import('@/views/AiAssistant.vue'), + meta: { title: 'AI 助手' } + }, + { + path: 'ai-settings', + name: 'AiSettings', + component: () => import('@/views/AiSettings.vue'), + meta: { title: 'AI 配置', roles: ['ADMIN'] } + }, + { + path: 'ai-yolo', + name: 'AiYolo', + component: () => import('@/views/AiYolo.vue'), + meta: { title: 'YOLO 权重', roles: ['ADMIN'] } + }, + { + path: 'ai-knowledge', + name: 'AiKnowledge', + component: () => import('@/views/AiKnowledge.vue'), + meta: { title: '知识库' } + }, + { + path: 'users', + name: 'Users', + component: () => import('@/views/Users.vue'), + meta: { title: '用户管理', roles: ['ADMIN'] } + }, + { + path: '403', + name: 'Forbidden', + component: () => import('@/views/Forbidden.vue'), + meta: { title: '无权访问' } + } + ] + }, + { path: '/:pathMatch(.*)*', redirect: '/dashboard' } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +router.beforeEach((to) => { + const store = useUserStore() + // 已登录访问登录页,直接进首页 + if (to.path === '/login' && store.token) { + return { path: '/dashboard' } + } + if (to.meta.public) return true + if (!store.token) { + return { path: '/login', query: { redirect: to.fullPath } } + } + if (to.meta.roles && !to.meta.roles.includes(store.role)) { + return { path: '/403' } + } + return true +}) + +export default router diff --git a/frontend/src/stores/user.js b/frontend/src/stores/user.js new file mode 100644 index 0000000..b454141 --- /dev/null +++ b/frontend/src/stores/user.js @@ -0,0 +1,41 @@ +import { defineStore } from 'pinia' +import { login as apiLogin, getMe } from '@/api/auth' + +const TOKEN_KEY = 'sh_token' +const USER_KEY = 'sh_user' + +export const useUserStore = defineStore('user', { + state: () => ({ + token: localStorage.getItem(TOKEN_KEY) || '', + userInfo: JSON.parse(localStorage.getItem(USER_KEY) || 'null') + }), + getters: { + role: (state) => state.userInfo?.role || '', + isAdmin: (state) => state.userInfo?.role === 'ADMIN', + displayName: (state) => state.userInfo?.realName || state.userInfo?.username || '' + }, + actions: { + async login(payload) { + const data = await apiLogin(payload) + this.token = data.token + this.userInfo = data.user + localStorage.setItem(TOKEN_KEY, data.token) + localStorage.setItem(USER_KEY, JSON.stringify(data.user)) + }, + async fetchMe() { + const user = await getMe() + this.userInfo = user + localStorage.setItem(USER_KEY, JSON.stringify(user)) + }, + setUserInfo(user) { + this.userInfo = user + localStorage.setItem(USER_KEY, JSON.stringify(user)) + }, + reset() { + this.token = '' + this.userInfo = null + localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(USER_KEY) + } + } +}) diff --git a/frontend/src/utils/labels.js b/frontend/src/utils/labels.js new file mode 100644 index 0000000..5e5f5c3 --- /dev/null +++ b/frontend/src/utils/labels.js @@ -0,0 +1,73 @@ +/** 枚举与状态的中文展示 */ + +export const genderLabel = (g) => + ({ MALE: '男', FEMALE: '女', OTHER: '其他' }[g] || g || '—') + +export const roleLabel = (r) => + ({ ADMIN: '管理员', DOCTOR: '医生', RADIOLOGIST: '影像医师' }[r] || r || '—') + +export const roleTagType = (r) => + ({ ADMIN: 'danger', DOCTOR: 'success', RADIOLOGIST: 'warning' }[r] || 'info') + +export const studyTypeLabel = (t) => + ({ X_RAY: 'X 光', CT: 'CT', MRI: 'MRI', ULTRASOUND: '超声' }[t] || t || '—') + +export const imagingStatusLabel = (s) => + ({ + PENDING: '待诊断', + ANALYZING: '分析中', + COMPLETED: '已完成', + ERROR: '失败' + }[s] || s || '—') + +export const imagingStatusType = (s) => + ({ + PENDING: 'info', + ANALYZING: 'warning', + COMPLETED: 'success', + ERROR: 'danger' + }[s] || 'info') + +export const riskLevelType = (level) => { + const l = String(level || '').toUpperCase() + if (l.includes('高') || l === 'HIGH') return 'danger' + if (l.includes('中') || l === 'MEDIUM') return 'warning' + if (l.includes('低') || l === 'LOW') return 'success' + return 'info' +} + +export const formatPercent = (v) => { + if (v == null || Number.isNaN(Number(v))) return '—' + const n = Number(v) + const pct = n <= 1 ? n * 100 : n + return `${pct.toFixed(1)}%` +} + +export const formatDateTime = (v) => { + if (!v) return '—' + return String(v).replace('T', ' ').slice(0, 19) +} + +export const appointmentStatusLabel = (s) => + ({ + SCHEDULED: '已预约', + CONFIRMED: '已确认', + COMPLETED: '已完成', + CANCELLED: '已取消', + NO_SHOW: '未到诊' + }[s] || s || '—') + +export const appointmentStatusType = (s) => + ({ + SCHEDULED: 'info', + CONFIRMED: 'primary', + COMPLETED: 'success', + CANCELLED: 'info', + NO_SHOW: 'danger' + }[s] || 'info') + +export const formatTime = (v) => { + if (!v) return '—' + return String(v).slice(0, 5) +} + diff --git a/frontend/src/utils/markdown.js b/frontend/src/utils/markdown.js new file mode 100644 index 0000000..cfdeb7c --- /dev/null +++ b/frontend/src/utils/markdown.js @@ -0,0 +1,24 @@ +import { marked } from 'marked' +import DOMPurify from 'dompurify' + +marked.setOptions({ + gfm: true, + breaks: true // 单个换行也转
+}) + +/** + * 将 Markdown 转为安全 HTML(助手消息展示用) + */ +export function renderMarkdown(text) { + if (text == null || text === '') return '' + const raw = String(text) + try { + const html = marked.parse(raw) + return DOMPurify.sanitize(html, { + USE_PROFILES: { html: true } + }) + } catch { + // 解析失败时退回纯文本转义 + return DOMPurify.sanitize(raw.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')) + } +} diff --git a/frontend/src/utils/request.js b/frontend/src/utils/request.js new file mode 100644 index 0000000..fae31f8 --- /dev/null +++ b/frontend/src/utils/request.js @@ -0,0 +1,59 @@ +import axios from 'axios' +import { ElMessage } from 'element-plus' +import { useUserStore } from '@/stores/user' +import router from '@/router' + +const service = axios.create({ + baseURL: '/api', + timeout: 20000 +}) + +service.interceptors.request.use((config) => { + const store = useUserStore() + if (store.token) { + config.headers = config.headers || {} + config.headers.Authorization = `Bearer ${store.token}` + } + return config +}) + +let lastAuthTipAt = 0 + +service.interceptors.response.use( + (response) => { + const data = response.data + if (typeof data === 'object' && data !== null && 'code' in data) { + if (data.code === 0) { + return data.data + } + ElMessage.error(data.message || '请求失败') + return Promise.reject(new Error(data.message || 'Error')) + } + return data + }, + (error) => { + const status = error.response?.status + const msg = error.response?.data?.message || error.message + if (status === 401) { + const store = useUserStore() + store.reset() + const now = Date.now() + if (now - lastAuthTipAt > 1500) { + lastAuthTipAt = now + ElMessage.error('登录已失效,请重新登录') + } + if (router.currentRoute.value.path !== '/login') { + router.push({ path: '/login', query: { redirect: router.currentRoute.value.fullPath } }) + } + } else if (status === 403) { + ElMessage.error('无权访问该资源') + } else if (error.code === 'ECONNABORTED') { + ElMessage.error('请求超时,请稍后重试') + } else { + ElMessage.error(msg || '网络异常') + } + return Promise.reject(error) + } +) + +export default service diff --git a/frontend/src/views/AiAssistant.vue b/frontend/src/views/AiAssistant.vue new file mode 100644 index 0000000..90da830 --- /dev/null +++ b/frontend/src/views/AiAssistant.vue @@ -0,0 +1,898 @@ + + + + + diff --git a/frontend/src/views/AiKnowledge.vue b/frontend/src/views/AiKnowledge.vue new file mode 100644 index 0000000..bc5f61e --- /dev/null +++ b/frontend/src/views/AiKnowledge.vue @@ -0,0 +1,322 @@ + + + + + diff --git a/frontend/src/views/AiSettings.vue b/frontend/src/views/AiSettings.vue new file mode 100644 index 0000000..1bed0b3 --- /dev/null +++ b/frontend/src/views/AiSettings.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/frontend/src/views/AiYolo.vue b/frontend/src/views/AiYolo.vue new file mode 100644 index 0000000..6820f08 --- /dev/null +++ b/frontend/src/views/AiYolo.vue @@ -0,0 +1,1312 @@ + + + + + diff --git a/frontend/src/views/Appointments.vue b/frontend/src/views/Appointments.vue new file mode 100644 index 0000000..51949a2 --- /dev/null +++ b/frontend/src/views/Appointments.vue @@ -0,0 +1,853 @@ + + + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..516751f --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,1218 @@ + + + + + diff --git a/frontend/src/views/Emrs.vue b/frontend/src/views/Emrs.vue new file mode 100644 index 0000000..a5e5563 --- /dev/null +++ b/frontend/src/views/Emrs.vue @@ -0,0 +1,490 @@ + + + + + diff --git a/frontend/src/views/Forbidden.vue b/frontend/src/views/Forbidden.vue new file mode 100644 index 0000000..17a41d8 --- /dev/null +++ b/frontend/src/views/Forbidden.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/frontend/src/views/Imaging.vue b/frontend/src/views/Imaging.vue new file mode 100644 index 0000000..b9dee9d --- /dev/null +++ b/frontend/src/views/Imaging.vue @@ -0,0 +1,1828 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..84adc96 --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/frontend/src/views/Patients.vue b/frontend/src/views/Patients.vue new file mode 100644 index 0000000..a1132c5 --- /dev/null +++ b/frontend/src/views/Patients.vue @@ -0,0 +1,454 @@ + + + + + + + + diff --git a/frontend/src/views/Users.vue b/frontend/src/views/Users.vue new file mode 100644 index 0000000..a805d76 --- /dev/null +++ b/frontend/src/views/Users.vue @@ -0,0 +1,840 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..bf47275 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,37 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' +import path from 'path' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + plugins: [ + vue(), + AutoImport({ resolvers: [ElementPlusResolver()] }), + Components({ resolvers: [ElementPlusResolver()] }) + ], + resolve: { + alias: { + '@': path.resolve(__dirname, 'src') + } + }, + server: { + host: '0.0.0.0', + port: 5173, + strictPort: true, + proxy: { + '/api': { + target: 'http://127.0.0.1:8080', + changeOrigin: true + }, + '/uploads': { + target: 'http://127.0.0.1:8080', + changeOrigin: true + } + } + } +}) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f65759d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "smart-hospital", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/ppt/assets/architecture.png b/ppt/assets/architecture.png new file mode 100644 index 0000000..6b3e848 Binary files /dev/null and b/ppt/assets/architecture.png differ diff --git a/ppt/assets/closing_bg.png b/ppt/assets/closing_bg.png new file mode 100644 index 0000000..05bc335 Binary files /dev/null and b/ppt/assets/closing_bg.png differ diff --git a/ppt/assets/cover_bg.png b/ppt/assets/cover_bg.png new file mode 100644 index 0000000..49a3dad Binary files /dev/null and b/ppt/assets/cover_bg.png differ diff --git a/ppt/assets/decision_flow.png b/ppt/assets/decision_flow.png new file mode 100644 index 0000000..a8d97ce Binary files /dev/null and b/ppt/assets/decision_flow.png differ diff --git a/ppt/assets/demo_timeline.png b/ppt/assets/demo_timeline.png new file mode 100644 index 0000000..af5c771 Binary files /dev/null and b/ppt/assets/demo_timeline.png differ diff --git a/ppt/assets/dual_chain.png b/ppt/assets/dual_chain.png new file mode 100644 index 0000000..ef8a3e2 Binary files /dev/null and b/ppt/assets/dual_chain.png differ diff --git a/ppt/assets/er_diagram.png b/ppt/assets/er_diagram.png new file mode 100644 index 0000000..50a594a Binary files /dev/null and b/ppt/assets/er_diagram.png differ diff --git a/ppt/assets/feature_panorama.png b/ppt/assets/feature_panorama.png new file mode 100644 index 0000000..5805ff0 Binary files /dev/null and b/ppt/assets/feature_panorama.png differ diff --git a/ppt/assets/imaging_flow.png b/ppt/assets/imaging_flow.png new file mode 100644 index 0000000..d425e67 Binary files /dev/null and b/ppt/assets/imaging_flow.png differ diff --git a/ppt/assets/llm_sync.png b/ppt/assets/llm_sync.png new file mode 100644 index 0000000..b4f6319 Binary files /dev/null and b/ppt/assets/llm_sync.png differ diff --git a/ppt/assets/module_tree.png b/ppt/assets/module_tree.png new file mode 100644 index 0000000..c868176 Binary files /dev/null and b/ppt/assets/module_tree.png differ diff --git a/ppt/assets/priority_board.png b/ppt/assets/priority_board.png new file mode 100644 index 0000000..d099fc2 Binary files /dev/null and b/ppt/assets/priority_board.png differ diff --git a/ppt/assets/scene_cards.png b/ppt/assets/scene_cards.png new file mode 100644 index 0000000..252a1fb Binary files /dev/null and b/ppt/assets/scene_cards.png differ diff --git a/ppt/assets/state_machine.png b/ppt/assets/state_machine.png new file mode 100644 index 0000000..899d2fe Binary files /dev/null and b/ppt/assets/state_machine.png differ diff --git a/ppt/assets/tech_layers.png b/ppt/assets/tech_layers.png new file mode 100644 index 0000000..ee487ee Binary files /dev/null and b/ppt/assets/tech_layers.png differ diff --git a/ppt/assets/transform.png b/ppt/assets/transform.png new file mode 100644 index 0000000..df460cf Binary files /dev/null and b/ppt/assets/transform.png differ diff --git a/ppt/assets/yolo_concept.png b/ppt/assets/yolo_concept.png new file mode 100644 index 0000000..346d19f Binary files /dev/null and b/ppt/assets/yolo_concept.png differ diff --git a/ppt/assets_v2/architecture.png b/ppt/assets_v2/architecture.png new file mode 100644 index 0000000..6862eaa Binary files /dev/null and b/ppt/assets_v2/architecture.png differ diff --git a/ppt/assets_v2/closing.png b/ppt/assets_v2/closing.png new file mode 100644 index 0000000..b1268f2 Binary files /dev/null and b/ppt/assets_v2/closing.png differ diff --git a/ppt/assets_v2/cover.png b/ppt/assets_v2/cover.png new file mode 100644 index 0000000..820945f Binary files /dev/null and b/ppt/assets_v2/cover.png differ diff --git a/ppt/assets_v2/decision.png b/ppt/assets_v2/decision.png new file mode 100644 index 0000000..74c2e17 Binary files /dev/null and b/ppt/assets_v2/decision.png differ diff --git a/ppt/assets_v2/demo.png b/ppt/assets_v2/demo.png new file mode 100644 index 0000000..cf391dc Binary files /dev/null and b/ppt/assets_v2/demo.png differ diff --git a/ppt/assets_v2/dual_chain.png b/ppt/assets_v2/dual_chain.png new file mode 100644 index 0000000..bcb660d Binary files /dev/null and b/ppt/assets_v2/dual_chain.png differ diff --git a/ppt/assets_v2/er.png b/ppt/assets_v2/er.png new file mode 100644 index 0000000..64782a7 Binary files /dev/null and b/ppt/assets_v2/er.png differ diff --git a/ppt/assets_v2/features.png b/ppt/assets_v2/features.png new file mode 100644 index 0000000..a05fa14 Binary files /dev/null and b/ppt/assets_v2/features.png differ diff --git a/ppt/assets_v2/goals.png b/ppt/assets_v2/goals.png new file mode 100644 index 0000000..4ff1a8f Binary files /dev/null and b/ppt/assets_v2/goals.png differ diff --git a/ppt/assets_v2/imaging_flow.png b/ppt/assets_v2/imaging_flow.png new file mode 100644 index 0000000..df40d64 Binary files /dev/null and b/ppt/assets_v2/imaging_flow.png differ diff --git a/ppt/assets_v2/modules.png b/ppt/assets_v2/modules.png new file mode 100644 index 0000000..57565fe Binary files /dev/null and b/ppt/assets_v2/modules.png differ diff --git a/ppt/assets_v2/nfr.png b/ppt/assets_v2/nfr.png new file mode 100644 index 0000000..3942b38 Binary files /dev/null and b/ppt/assets_v2/nfr.png differ diff --git a/ppt/assets_v2/pain.png b/ppt/assets_v2/pain.png new file mode 100644 index 0000000..6aaedac Binary files /dev/null and b/ppt/assets_v2/pain.png differ diff --git a/ppt/assets_v2/priority.png b/ppt/assets_v2/priority.png new file mode 100644 index 0000000..e863ee1 Binary files /dev/null and b/ppt/assets_v2/priority.png differ diff --git a/ppt/assets_v2/scenes.png b/ppt/assets_v2/scenes.png new file mode 100644 index 0000000..47f4ad8 Binary files /dev/null and b/ppt/assets_v2/scenes.png differ diff --git a/ppt/assets_v2/state.png b/ppt/assets_v2/state.png new file mode 100644 index 0000000..910c361 Binary files /dev/null and b/ppt/assets_v2/state.png differ diff --git a/ppt/assets_v2/team.png b/ppt/assets_v2/team.png new file mode 100644 index 0000000..3cd62ff Binary files /dev/null and b/ppt/assets_v2/team.png differ diff --git a/ppt/assets_v2/team_lane.png b/ppt/assets_v2/team_lane.png new file mode 100644 index 0000000..ba5b960 Binary files /dev/null and b/ppt/assets_v2/team_lane.png differ diff --git a/ppt/assets_v2/tech.png b/ppt/assets_v2/tech.png new file mode 100644 index 0000000..eed0638 Binary files /dev/null and b/ppt/assets_v2/tech.png differ diff --git a/ppt/assets_v2/yolo.png b/ppt/assets_v2/yolo.png new file mode 100644 index 0000000..444fd05 Binary files /dev/null and b/ppt/assets_v2/yolo.png differ diff --git a/ppt/build_ppt.py b/ppt/build_ppt.py new file mode 100644 index 0000000..bd7959b --- /dev/null +++ b/ppt/build_ppt.py @@ -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'' + ) + 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 + 全局异常 | 可扩展: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", [ + '{ "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() diff --git a/ppt/build_ppt_v2.py b/ppt/build_ppt_v2.py new file mode 100644 index 0000000..2a931c1 --- /dev/null +++ b/ppt/build_ppt_v2.py @@ -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'' + ) + 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 + 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() diff --git a/ppt/check_overlap.py b/ppt/check_overlap.py new file mode 100644 index 0000000..0bf4f40 --- /dev/null +++ b/ppt/check_overlap.py @@ -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}") diff --git a/ppt/gen_assets.py b/ppt/gen_assets.py new file mode 100644 index 0000000..6c5667e --- /dev/null +++ b/ppt/gen_assets.py @@ -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) diff --git a/ppt/gen_assets_v2.py b/ppt/gen_assets_v2.py new file mode 100644 index 0000000..65b3ac7 --- /dev/null +++ b/ppt/gen_assets_v2.py @@ -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\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) diff --git a/ppt/智慧医院_AI影像诊断与电子病历辅助决策系统_答辩PPT.pptx b/ppt/智慧医院_AI影像诊断与电子病历辅助决策系统_答辩PPT.pptx new file mode 100644 index 0000000..2e93c93 Binary files /dev/null and b/ppt/智慧医院_AI影像诊断与电子病历辅助决策系统_答辩PPT.pptx differ diff --git a/ppt/智慧医院_答辩PPT_优化版.pptx b/ppt/智慧医院_答辩PPT_优化版.pptx new file mode 100644 index 0000000..cb5da6d Binary files /dev/null and b/ppt/智慧医院_答辩PPT_优化版.pptx differ diff --git a/ppt/智慧医院_项目答辩PPT.pptx b/ppt/智慧医院_项目答辩PPT.pptx new file mode 100644 index 0000000..0f62730 Binary files /dev/null and b/ppt/智慧医院_项目答辩PPT.pptx differ diff --git a/ppt/智慧医院_项目答辩PPT_v2.pptx b/ppt/智慧医院_项目答辩PPT_v2.pptx new file mode 100644 index 0000000..3eb7213 Binary files /dev/null and b/ppt/智慧医院_项目答辩PPT_v2.pptx differ diff --git a/ppt/项目详细文档.md b/ppt/项目详细文档.md new file mode 100644 index 0000000..b5432d0 --- /dev/null +++ b/ppt/项目详细文档.md @@ -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` + `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`,`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`(可能过时) | + +--- + +**文档结束** diff --git a/smart-hospital b/smart-hospital new file mode 160000 index 0000000..74db6c3 --- /dev/null +++ b/smart-hospital @@ -0,0 +1 @@ +Subproject commit 74db6c3c9de1d8cf0d9a6d855b55b7f3bc3d4ee7 diff --git a/smart-hospital.sql b/smart-hospital.sql new file mode 100644 index 0000000..ed89cc4 --- /dev/null +++ b/smart-hospital.sql @@ -0,0 +1,273 @@ +-- ============================================================ +-- 智慧医院 AI 影像诊断与电子病历辅助决策系统 +-- 数据库初始化脚本(MySQL 8+ / 兼容 5.7+) +-- ============================================================ +-- 版本:2026-07-23(与当前 JPA 实体 + AI/YOLO 文件侧配置同步) +-- +-- 【说明】 +-- 1. 本脚本:MySQL 建库建表(spring.profiles.active=mysql 时使用)。 +-- 2. 默认开发:H2 内存库 + spring.jpa.hibernate.ddl-auto=update, +-- 可不执行本脚本,表结构由 JPA 自动维护。 +-- 3. 初始账号由 DataInitializer 启动写入(BCrypt): +-- admin / admin123 角色 ADMIN +-- doctor1 / pass123 角色 DOCTOR +-- radio1 / radio123 角色 RADIOLOGIST +-- 4. 以下内容【不在 MySQL】,落在文件系统: +-- · YOLO 权重:ai-service/data/weights/*.pt +-- 推荐医学权重:chest-xray-yolov8-detect.pt(14 类胸部病灶) +-- 另有 pneumonia-yolov8n.pt、rsna-pneumonia-yolov8s.pt、yolov8n.pt 等 +-- · YOLO 配置/逻辑删除列表:ai-service/data/yolo_config.json +-- (deleted_weights 为逻辑删除,磁盘 .pt 仍保留) +-- · YOLO 推理统计:ai-service/data/yolo_stats.json +-- · 部分 AI 配置/对话:./data/ai/(AiFileStore,视实现而定) +-- · 影像原图/标注图:./uploads/images/(含 ai-annotated/) +-- 5. 执行:mysql -u root -p < smart-hospital.sql +-- ============================================================ + +CREATE DATABASE IF NOT EXISTS smart_hospital + CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE smart_hospital; + +-- ------------------------------------------------------------ +-- 1. 用户 users (User.java) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS users ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + username VARCHAR(50) NOT NULL, + password VARCHAR(255) NOT NULL COMMENT 'BCrypt 哈希', + real_name VARCHAR(50) NOT NULL, + role ENUM('DOCTOR', 'RADIOLOGIST', 'ADMIN') NOT NULL, + department VARCHAR(100) NULL, + phone VARCHAR(20) NULL, + email VARCHAR(100) NULL, + avatar VARCHAR(500) NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_users_username (username) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------ +-- 2. 患者 patients (Patient.java) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS patients ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(50) NOT NULL, + gender ENUM('MALE', 'FEMALE', 'OTHER') NOT NULL, + age INT NOT NULL, + id_card VARCHAR(18) NULL, + phone VARCHAR(20) NULL, + address TEXT NULL, + medical_history TEXT NULL, + created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_patients_id_card (id_card) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------ +-- 3. 影像检查 imaging_records (ImagingRecord.java) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS imaging_records ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + patient_id BIGINT NOT NULL, + doctor_id BIGINT NOT NULL, + study_type ENUM('X_RAY', 'CT', 'MRI', 'ULTRASOUND') NOT NULL, + body_part VARCHAR(100) NOT NULL, + image_url VARCHAR(500) NOT NULL COMMENT '如 /uploads/images/...', + status ENUM('PENDING', 'ANALYZING', 'COMPLETED', 'ERROR') + NOT NULL DEFAULT 'PENDING', + ai_diagnosis TEXT NULL COMMENT 'AI 诊断摘要(列表展示)', + ai_confidence DECIMAL(5,4) NULL, + created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_imaging_patient (patient_id), + KEY idx_imaging_doctor (doctor_id), + KEY idx_imaging_status (status), + KEY idx_imaging_study_type (study_type), + CONSTRAINT fk_imaging_patient FOREIGN KEY (patient_id) REFERENCES patients(id), + CONSTRAINT fk_imaging_doctor FOREIGN KEY (doctor_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------ +-- 4. AI 诊断结果 ai_diagnosis_results (AIDiagnosisResult.java) +-- 含 YOLO 检测框 / 标注图 / 完整报告等扩展字段 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS ai_diagnosis_results ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + imaging_record_id BIGINT NOT NULL, + diagnosis_text TEXT NOT NULL, + confidence_score DECIMAL(5,4) NOT NULL, + findings TEXT NULL, + recommendations TEXT NULL, + model_version VARCHAR(50) NULL, + processing_time_ms BIGINT NULL, + -- YOLO / FastAPI 扩展(与 AIDiagnosisResult 实体一致) + detections_json TEXT NULL COMMENT '检测框 JSON 数组', + annotated_image_url VARCHAR(512) NULL COMMENT '标注图路径 /uploads/images/ai-annotated/...', + full_report TEXT NULL COMMENT '完整影像诊断报告', + engine VARCHAR(64) NULL COMMENT 'fastapi-yolo / fallback 等', + fallback_flag BOOLEAN NULL COMMENT '是否本地规则降级', + created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_ai_diag_imaging (imaging_record_id), + CONSTRAINT fk_ai_diag_imaging FOREIGN KEY (imaging_record_id) + REFERENCES imaging_records(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------ +-- 5. 电子病历 electronic_medical_records (ElectronicMedicalRecord.java) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS electronic_medical_records ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + patient_id BIGINT NOT NULL, + doctor_id BIGINT NOT NULL, + visit_date DATE NOT NULL, + chief_complaint TEXT NULL, + present_illness TEXT NULL, + physical_examination TEXT NULL, + diagnosis TEXT NOT NULL, + treatment_plan TEXT NULL, + medications TEXT NULL, + follow_up_notes TEXT NULL, + ai_suggestions TEXT NULL COMMENT 'AI 辅助决策建议 JSON/文本', + created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_emr_patient (patient_id), + KEY idx_emr_doctor (doctor_id), + KEY idx_emr_visit_date (visit_date), + CONSTRAINT fk_emr_patient FOREIGN KEY (patient_id) REFERENCES patients(id), + CONSTRAINT fk_emr_doctor FOREIGN KEY (doctor_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------ +-- 6. 辅助决策记录 decision_support_records (DecisionSupportRecord.java) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS decision_support_records ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + emr_id BIGINT NOT NULL, + query_text TEXT NOT NULL, + ai_response TEXT NOT NULL, + confidence_score DECIMAL(5,4) NULL, + references_used TEXT NULL, + doctor_feedback ENUM('HELPFUL', 'NOT_HELPFUL', 'NEUTRAL') NULL, + created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_ds_emr (emr_id), + CONSTRAINT fk_ds_emr FOREIGN KEY (emr_id) + REFERENCES electronic_medical_records(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------ +-- 7. 预约挂号 appointments (Appointment.java) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS appointments ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + patient_id BIGINT NOT NULL, + doctor_id BIGINT NOT NULL, + department VARCHAR(64) NOT NULL, + appointment_date DATE NOT NULL, + appointment_time TIME NULL, + reason VARCHAR(128) NULL, + notes TEXT NULL, + status ENUM('SCHEDULED', 'CONFIRMED', 'COMPLETED', 'CANCELLED', 'NO_SHOW') + NOT NULL DEFAULT 'SCHEDULED', + created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_appt_patient (patient_id), + KEY idx_appt_doctor (doctor_id), + KEY idx_appt_date (appointment_date), + KEY idx_appt_status (status), + CONSTRAINT fk_appt_patient FOREIGN KEY (patient_id) REFERENCES patients(id), + CONSTRAINT fk_appt_doctor FOREIGN KEY (doctor_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------ +-- 8. AI 配置 ai_settings (AiSettings.java,单行 id=1) +-- 亦可由 ./data/ai 文件持久化,视部署配置而定 +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS ai_settings ( + id BIGINT PRIMARY KEY, + api_base_url VARCHAR(500) NULL, + api_key VARCHAR(500) NULL, + model VARCHAR(120) NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + temperature DOUBLE NOT NULL DEFAULT 0.7, + system_prompt TEXT NULL, + updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------ +-- 9. AI 知识库 ai_knowledge_docs (AiKnowledgeDoc.java) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS ai_knowledge_docs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + title VARCHAR(200) NOT NULL, + category VARCHAR(64) NULL, + content TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_knowledge_category (category), + KEY idx_knowledge_enabled (enabled) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------ +-- 10. AI 对话历史 ai_chat_messages (AiChatMessage.java) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS ai_chat_messages ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + mode VARCHAR(16) NOT NULL COMMENT '如 chat / rag 等', + question TEXT NOT NULL, + answer TEXT NOT NULL, + sources_json TEXT NULL COMMENT '引用知识片段 JSON', + created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_chat_user (user_id), + KEY idx_chat_created (created_at), + CONSTRAINT fk_chat_user FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ============================================================ +-- 已有库升级片段(旧 MySQL → 当前结构) +-- 新库请直接用上方 CREATE;旧库按需取消注释执行 +-- (重复执行可能报 Duplicate column / key,可忽略) +-- ============================================================ +-- ALTER TABLE ai_diagnosis_results ADD COLUMN detections_json TEXT NULL COMMENT '检测框 JSON'; +-- ALTER TABLE ai_diagnosis_results ADD COLUMN annotated_image_url VARCHAR(512) NULL COMMENT '标注图路径'; +-- ALTER TABLE ai_diagnosis_results ADD COLUMN full_report TEXT NULL COMMENT '完整报告'; +-- ALTER TABLE ai_diagnosis_results ADD COLUMN engine VARCHAR(64) NULL COMMENT '引擎标识'; +-- ALTER TABLE ai_diagnosis_results ADD COLUMN fallback_flag BOOLEAN NULL COMMENT '是否降级'; +-- +-- CREATE INDEX idx_imaging_status ON imaging_records (status); +-- CREATE INDEX idx_imaging_study_type ON imaging_records (study_type); +-- CREATE INDEX idx_ai_diag_imaging ON ai_diagnosis_results (imaging_record_id); +-- CREATE INDEX idx_appt_date ON appointments (appointment_date); + +-- ============================================================ +-- 可选示例数据(默认由 DataInitializer 写入,无需手动执行) +-- 密码必须为 BCrypt,勿写明文。演示账号明文仅作对照: +-- admin/admin123 doctor1/pass123 radio1/radio123 +-- ============================================================ +-- INSERT INTO users (username, password, real_name, role, department, phone, email, enabled) VALUES +-- ('admin', '', '系统管理员', 'ADMIN', '信息科', '13800000000', 'admin@hospital.com', TRUE), +-- ('doctor1', '', '张医生', 'DOCTOR', '内科', '13800138000', 'zhang@hospital.com', TRUE), +-- ('radio1', '', '王影像师', 'RADIOLOGIST', '影像科', '13800138111', 'wang@hospital.com', TRUE); +-- +-- INSERT INTO patients (name, gender, age, id_card, phone, address, medical_history) VALUES +-- ('李明', 'MALE', 45, '110101199001011234', '13900139000', '北京市朝阳区', '高血压病史5年'); +-- +-- INSERT INTO imaging_records (patient_id, doctor_id, study_type, body_part, image_url, status) VALUES +-- (1, 2, 'CT', '胸部', '/uploads/images/demo_chest.png', 'PENDING'); +-- +-- INSERT INTO appointments (patient_id, doctor_id, department, appointment_date, appointment_time, reason, notes, status) VALUES +-- (1, 2, '内科', DATE_ADD(CURDATE(), INTERVAL 1 DAY), '09:30:00', '复诊随访', '示例预约', 'SCHEDULED'); + +-- ============================================================ +-- 非数据库:YOLO 权重侧配置示意(勿当 SQL 执行) +-- 文件:ai-service/data/yolo_config.json +-- { +-- "active_weight": "chest-xray-yolov8-detect.pt", +-- "demo_mode": "auto", +-- "deleted_weights": [], +-- "updated_at": "..." +-- } +-- deleted_weights = 逻辑删除列表(列表隐藏,.pt 文件仍在 weights 目录) +-- ============================================================ diff --git a/uploads/images/2026-07-23/22271272658c40d1982e58bb2a02f0be.jpg b/uploads/images/2026-07-23/22271272658c40d1982e58bb2a02f0be.jpg new file mode 100644 index 0000000..7b3ee0f Binary files /dev/null and b/uploads/images/2026-07-23/22271272658c40d1982e58bb2a02f0be.jpg differ diff --git a/uploads/images/2026-07-23/5e25e58583ec4cd69723041504e8ddd4.jpeg b/uploads/images/2026-07-23/5e25e58583ec4cd69723041504e8ddd4.jpeg new file mode 100644 index 0000000..3cf2588 Binary files /dev/null and b/uploads/images/2026-07-23/5e25e58583ec4cd69723041504e8ddd4.jpeg differ diff --git a/uploads/images/2026-07-23/66b8681f39a04bb085b740b87d24f685.jpeg b/uploads/images/2026-07-23/66b8681f39a04bb085b740b87d24f685.jpeg new file mode 100644 index 0000000..479f20a Binary files /dev/null and b/uploads/images/2026-07-23/66b8681f39a04bb085b740b87d24f685.jpeg differ diff --git a/uploads/images/2026-07-23/6ba816025a4342aab867e827fc797920.jpeg b/uploads/images/2026-07-23/6ba816025a4342aab867e827fc797920.jpeg new file mode 100644 index 0000000..58f8178 Binary files /dev/null and b/uploads/images/2026-07-23/6ba816025a4342aab867e827fc797920.jpeg differ diff --git a/uploads/images/2026-07-23/7d3e2f7174a648f6b3c9981673389b51.jpeg b/uploads/images/2026-07-23/7d3e2f7174a648f6b3c9981673389b51.jpeg new file mode 100644 index 0000000..479f20a Binary files /dev/null and b/uploads/images/2026-07-23/7d3e2f7174a648f6b3c9981673389b51.jpeg differ diff --git a/uploads/images/2026-07-23/9d0fbd7a94524deb968fb19c0937221e.jpeg b/uploads/images/2026-07-23/9d0fbd7a94524deb968fb19c0937221e.jpeg new file mode 100644 index 0000000..6b26eca Binary files /dev/null and b/uploads/images/2026-07-23/9d0fbd7a94524deb968fb19c0937221e.jpeg differ diff --git a/uploads/images/ai-annotated/2026-07-23/25fc7bbee2634b0b85f50d4cbd324977.jpg b/uploads/images/ai-annotated/2026-07-23/25fc7bbee2634b0b85f50d4cbd324977.jpg new file mode 100644 index 0000000..a1a1f86 Binary files /dev/null and b/uploads/images/ai-annotated/2026-07-23/25fc7bbee2634b0b85f50d4cbd324977.jpg differ diff --git a/uploads/images/ai-annotated/2026-07-23/313278f0732749e49c31669382748f23.jpg b/uploads/images/ai-annotated/2026-07-23/313278f0732749e49c31669382748f23.jpg new file mode 100644 index 0000000..840af8f Binary files /dev/null and b/uploads/images/ai-annotated/2026-07-23/313278f0732749e49c31669382748f23.jpg differ diff --git a/uploads/images/ai-annotated/2026-07-23/4dd0f78a551d44df82c041b9ee8eb36e.jpg b/uploads/images/ai-annotated/2026-07-23/4dd0f78a551d44df82c041b9ee8eb36e.jpg new file mode 100644 index 0000000..840af8f Binary files /dev/null and b/uploads/images/ai-annotated/2026-07-23/4dd0f78a551d44df82c041b9ee8eb36e.jpg differ diff --git a/uploads/images/ai-annotated/2026-07-23/7e4717ceb26e4702ba12d31563324ae9.jpg b/uploads/images/ai-annotated/2026-07-23/7e4717ceb26e4702ba12d31563324ae9.jpg new file mode 100644 index 0000000..6a9a8c5 Binary files /dev/null and b/uploads/images/ai-annotated/2026-07-23/7e4717ceb26e4702ba12d31563324ae9.jpg differ diff --git a/uploads/images/ai-annotated/2026-07-23/aaa83b6575dd4719be607938ad614c1a.jpg b/uploads/images/ai-annotated/2026-07-23/aaa83b6575dd4719be607938ad614c1a.jpg new file mode 100644 index 0000000..ec15b1f Binary files /dev/null and b/uploads/images/ai-annotated/2026-07-23/aaa83b6575dd4719be607938ad614c1a.jpg differ diff --git a/uploads/images/ai-annotated/2026-07-23/bcfb29d7ef934a5792715d04e8b0e074.jpg b/uploads/images/ai-annotated/2026-07-23/bcfb29d7ef934a5792715d04e8b0e074.jpg new file mode 100644 index 0000000..42f8689 Binary files /dev/null and b/uploads/images/ai-annotated/2026-07-23/bcfb29d7ef934a5792715d04e8b0e074.jpg differ diff --git a/uploads/images/ai-annotated/2026-07-23/e4156465f9c847f79a81b032b22d6d99.jpg b/uploads/images/ai-annotated/2026-07-23/e4156465f9c847f79a81b032b22d6d99.jpg new file mode 100644 index 0000000..13c2d10 Binary files /dev/null and b/uploads/images/ai-annotated/2026-07-23/e4156465f9c847f79a81b032b22d6d99.jpg differ diff --git a/xuqiu/scripts/generate_requirements_ppt.py b/xuqiu/scripts/generate_requirements_ppt.py new file mode 100644 index 0000000..56ee92d --- /dev/null +++ b/xuqiu/scripts/generate_requirements_ppt.py @@ -0,0 +1,1052 @@ +# -*- coding: utf-8 -*- +"""生成《智慧医院 AI 影像诊断与电子病历辅助决策系统》需求答辩 PPT。""" + +from __future__ import annotations + +from pathlib import Path + +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.shapes import MSO_CONNECTOR, MSO_SHAPE +from pptx.enum.text import MSO_ANCHOR, PP_ALIGN +from pptx.oxml.ns import qn +from pptx.util import Emu, Inches, Pt + +# ---------- 主题 ---------- +DARK = RGBColor(0x0B, 0x3A, 0x5C) +TEAL = RGBColor(0x1A, 0xBC, 0x9C) +TEAL_DARK = RGBColor(0x12, 0x8A, 0x72) +LIGHT = RGBColor(0xF5, 0xF8, 0xFB) +WHITE = RGBColor(0xFF, 0xFF, 0xFF) +GRAY = RGBColor(0x5A, 0x6A, 0x7A) +GRAY_LIGHT = RGBColor(0xE8, 0xEE, 0xF3) +ORANGE = RGBColor(0xE6, 0x7E, 0x22) +RED_SOFT = RGBColor(0xC0, 0x39, 0x2B) +CARD_BG = RGBColor(0xFF, 0xFF, 0xFF) +ROW_ALT = RGBColor(0xEE, 0xF6, 0xF4) + +FONT = "微软雅黑" +SLIDE_W = Inches(13.333) +SLIDE_H = Inches(7.5) + +OUT_PATH = Path(__file__).resolve().parent.parent / "智慧医院-需求答辩PPT.pptx" + +MEMBERS = [ + ("成员 A", "项目负责人 / 需求分析", "需求梳理、文档、答辩统筹、验收", "全局 P0–P2", "需求规格、答辩 PPT、演示剧本"), + ("成员 B", "前端负责人", "布局/路由守卫/仪表盘/通用组件", "F-01/06/08/14/16", "页面路由、ECharts、交互体验"), + ("成员 C", "前端业务开发", "患者/预约/病历/影像结果展示", "F-02/03/05/07", "业务页、360°档案、报告弹窗"), + ("成员 D", "业务后端负责人", "JWT、权限、统一响应、用户管理", "F-01/06/11/12", "REST、Security、种子账号"), + ("成员 E", "后端业务 + 数据", "CRUD、异步诊断编排、降级", "F-02–05/07/13", "JPA、状态机、AI 客户端"), + ("成员 F", "AI 微服务", "FastAPI、YOLO、RAG/LLM、知识库", "F-04/05/09–12", "检测报告、决策、权重管理"), +] + + +def set_run_font(run, size=14, bold=False, color=DARK, font_name=FONT): + run.font.size = Pt(size) + run.font.bold = bold + run.font.color.rgb = color + run.font.name = font_name + rPr = run._r.get_or_add_rPr() + ea = rPr.get_or_add_ea() if hasattr(rPr, "get_or_add_ea") else None + # east asian font + from lxml import etree + + for child in list(rPr): + if child.tag.endswith("}ea") or child.tag.endswith("}cs"): + pass + ea_elem = rPr.find(qn("a:ea")) + if ea_elem is None: + ea_elem = etree.SubElement(rPr, qn("a:ea")) + ea_elem.set("typeface", font_name) + + +def add_textbox(slide, left, top, width, height, text, size=14, bold=False, color=DARK, align=PP_ALIGN.LEFT, font_name=FONT): + box = slide.shapes.add_textbox(left, top, width, height) + tf = box.text_frame + tf.word_wrap = True + p = tf.paragraphs[0] + p.alignment = align + run = p.add_run() + run.text = text + set_run_font(run, size=size, bold=bold, color=color, font_name=font_name) + return box + + +def add_paragraph(tf, text, size=13, bold=False, color=DARK, align=PP_ALIGN.LEFT, space_before=0, space_after=4): + p = tf.add_paragraph() if tf.paragraphs[0].text or len(tf.paragraphs) > 1 or (tf.paragraphs[0].runs) else tf.paragraphs[0] + if p.text and p.runs: + p = tf.add_paragraph() + p.alignment = align + p.space_before = Pt(space_before) + p.space_after = Pt(space_after) + run = p.add_run() + run.text = text + set_run_font(run, size=size, bold=bold, color=color) + return p + + +def fill_shape(shape, color: RGBColor): + shape.fill.solid() + shape.fill.fore_color.rgb = color + shape.line.fill.background() + + +def add_rect(slide, left, top, width, height, fill=TEAL, line=None): + shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, width, height) + fill_shape(shape, fill) + if line is not None: + shape.line.color.rgb = line + shape.line.width = Pt(1) + else: + shape.line.fill.background() + # softer corners + try: + shape.adjustments[0] = 0.08 + except Exception: + pass + return shape + + +def add_rect_sharp(slide, left, top, width, height, fill=DARK): + shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, top, width, height) + fill_shape(shape, fill) + return shape + + +def set_shape_text(shape, text, size=12, bold=False, color=WHITE, align=PP_ALIGN.CENTER, valign=MSO_ANCHOR.MIDDLE): + tf = shape.text_frame + tf.word_wrap = True + tf.auto_size = None + tf.paragraphs[0].alignment = align + shape.text_frame.paragraphs[0].clear() if False else None + p = tf.paragraphs[0] + p.alignment = align + # clear existing + for r in list(p.runs): + r.text = "" + if p.runs: + run = p.runs[0] + run.text = text + else: + run = p.add_run() + run.text = text + set_run_font(run, size=size, bold=bold, color=color) + tf.paragraphs[0].alignment = align + try: + tf._txBody.bodyPr.set("anchor", "ctr" if valign == MSO_ANCHOR.MIDDLE else "t") + except Exception: + pass + + +def clear_and_set_text(shape, lines, size=12, bold=False, color=WHITE, align=PP_ALIGN.CENTER): + """lines: str or list of (text, size, bold, color).""" + tf = shape.text_frame + tf.word_wrap = True + if isinstance(lines, str): + lines = [(lines, size, bold, color)] + # first paragraph + p0 = tf.paragraphs[0] + p0.alignment = align + # remove extra paragraphs + for i, item in enumerate(lines): + if len(item) == 4: + t, s, b, c = item + else: + t = item[0] + s = item[1] if len(item) > 1 else size + b = item[2] if len(item) > 2 else bold + c = item[3] if len(item) > 3 else color + if i == 0: + p = p0 + if p.runs: + p.runs[0].text = t + set_run_font(p.runs[0], size=s, bold=b, color=c) + for extra in p.runs[1:]: + extra.text = "" + else: + run = p.add_run() + run.text = t + set_run_font(run, size=s, bold=b, color=c) + else: + p = tf.add_paragraph() + p.alignment = align + run = p.add_run() + run.text = t + set_run_font(run, size=s, bold=b, color=c) + try: + tf._txBody.bodyPr.set("anchor", "ctr") + except Exception: + pass + + +def add_footer(slide, page_no, total=18): + add_rect_sharp(slide, 0, Inches(7.15), SLIDE_W, Inches(0.35), fill=DARK) + add_textbox( + slide, + Inches(0.4), + Inches(7.18), + Inches(8), + Inches(0.28), + "智慧医院 · 需求答辩 | 仅供教学实训演示", + size=10, + color=WHITE, + ) + add_textbox( + slide, + Inches(11.2), + Inches(7.18), + Inches(1.7), + Inches(0.28), + f"{page_no} / {total}", + size=10, + color=WHITE, + align=PP_ALIGN.RIGHT, + ) + + +def add_header_bar(slide, title, subtitle=None): + add_rect_sharp(slide, 0, 0, SLIDE_W, Inches(0.95), fill=DARK) + add_rect_sharp(slide, 0, Inches(0.95), SLIDE_W, Inches(0.08), fill=TEAL) + add_textbox(slide, Inches(0.45), Inches(0.22), Inches(12), Inches(0.45), title, size=24, bold=True, color=WHITE) + if subtitle: + add_textbox(slide, Inches(0.45), Inches(0.58), Inches(12), Inches(0.3), subtitle, size=11, color=TEAL) + + +def add_bg(slide): + add_rect_sharp(slide, 0, 0, SLIDE_W, SLIDE_H, fill=LIGHT) + + +def style_table(table, header=True, col_widths=None): + for r_idx, row in enumerate(table.rows): + for c_idx, cell in enumerate(row.cells): + cell.vertical_anchor = MSO_ANCHOR.MIDDLE + # background + tc = cell._tc + tcPr = tc.get_or_add_tcPr() + # remove existing solid fill + for child in list(tcPr): + if child.tag.endswith("}solidFill") or child.tag.endswith("}//a:solidFill"): + tcPr.remove(child) + from lxml import etree + + solid = etree.SubElement(tcPr, qn("a:solidFill")) + srgb = etree.SubElement(solid, qn("a:srgbClr")) + if header and r_idx == 0: + srgb.set("val", "0B3A5C") + elif r_idx % 2 == 0: + srgb.set("val", "EEF6F4") + else: + srgb.set("val", "FFFFFF") + + for p in cell.text_frame.paragraphs: + p.alignment = PP_ALIGN.CENTER + for run in p.runs: + run.font.name = FONT + run.font.size = Pt(11 if r_idx > 0 else 12) + run.font.bold = r_idx == 0 + if header and r_idx == 0: + run.font.color.rgb = WHITE + else: + run.font.color.rgb = DARK + rPr = run._r.get_or_add_rPr() + from lxml import etree as ET + + ea = rPr.find(qn("a:ea")) + if ea is None: + ea = ET.SubElement(rPr, qn("a:ea")) + ea.set("typeface", FONT) + if col_widths: + for i, w in enumerate(col_widths): + for cell in table.columns[i].cells if False else []: + pass + table.columns[i].width = w + + +def fill_table(table, data, header=True): + for r, row_data in enumerate(data): + for c, val in enumerate(row_data): + cell = table.cell(r, c) + cell.text = str(val) + for p in cell.text_frame.paragraphs: + for run in p.runs: + run.font.name = FONT + style_table(table, header=header) + + +def add_table(slide, left, top, width, height, rows, cols, data, col_widths=None): + table_shape = slide.shapes.add_table(rows, cols, left, top, width, height) + table = table_shape.table + if col_widths: + for i, w in enumerate(col_widths): + table.columns[i].width = w + fill_table(table, data) + return table + + +def card(slide, left, top, width, height, title, body, accent=TEAL): + shape = add_rect(slide, left, top, width, height, fill=WHITE, line=GRAY_LIGHT) + bar = add_rect_sharp(slide, left, top, Inches(0.12), height, fill=accent) + add_textbox(slide, left + Inches(0.25), top + Inches(0.12), width - Inches(0.35), Inches(0.35), title, size=14, bold=True, color=DARK) + box = slide.shapes.add_textbox(left + Inches(0.25), top + Inches(0.45), width - Inches(0.4), height - Inches(0.55)) + tf = box.text_frame + tf.word_wrap = True + p = tf.paragraphs[0] + run = p.add_run() + run.text = body + set_run_font(run, size=11, color=GRAY) + return shape + + +# ==================== 各页 ==================== + + +def slide_cover(prs): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_rect_sharp(slide, 0, 0, SLIDE_W, SLIDE_H, fill=DARK) + # decorative shapes + add_rect(slide, Inches(9.5), Inches(-0.5), Inches(5), Inches(4), fill=TEAL) + s = slide.shapes[-1] + try: + s.adjustments[0] = 0.2 + except Exception: + pass + # make teal semi by using another block + add_rect_sharp(slide, Inches(10.8), Inches(4.2), Inches(3), Inches(4), fill=TEAL_DARK) + add_rect_sharp(slide, 0, Inches(6.6), SLIDE_W, Inches(0.9), fill=TEAL) + + add_textbox(slide, Inches(0.7), Inches(1.3), Inches(9), Inches(0.4), "高校实训 · 需求答辩", size=16, color=TEAL) + add_textbox( + slide, + Inches(0.7), + Inches(1.9), + Inches(10), + Inches(1.4), + "智慧医院 AI 影像诊断与\n电子病历辅助决策系统", + size=32, + bold=True, + color=WHITE, + ) + add_textbox( + slide, + Inches(0.7), + Inches(3.6), + Inches(9), + Inches(0.5), + "Smart Hospital | 业务闭环 × 可演示 AI × 前后端分离", + size=14, + color=RGBColor(0xB8, 0xD4, 0xE8), + ) + add_textbox( + slide, + Inches(0.7), + Inches(4.5), + Inches(9), + Inches(0.8), + "小组组员:成员 A · 成员 B · 成员 C · 成员 D · 成员 E · 成员 F", + size=13, + color=WHITE, + ) + add_textbox(slide, Inches(0.7), Inches(5.2), Inches(8), Inches(0.4), "文档版本 v1.0 | 2026-07", size=12, color=RGBColor(0xB8, 0xD4, 0xE8)) + add_textbox( + slide, + Inches(0.7), + Inches(6.75), + Inches(12), + Inches(0.4), + "本系统输出仅供教学实训与辅助决策演示,不能替代执业医师正式诊断", + size=11, + color=WHITE, + ) + + +def slide_toc(prs): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_bg(slide) + add_header_bar(slide, "汇报目录", "需求答辩六大板块 · 约 15 分钟讲述节奏") + items = [ + ("01", "背景与目标", "痛点、主题定位、建设目标与应用场景"), + ("02", "角色与需求", "三角色诉求、P0/P1/P2 优先级与验收要点"), + ("03", "功能需求深挖", "影像 AI、病历决策、配套业务与功能全景"), + ("04", "非功能与边界", "性能/安全/可用性、约束假设与已知边界"), + ("05", "方案如何落地", "逻辑架构与技术选型如何服务需求"), + ("06", "组织与验收", "六人分工、演示剧本、总结与展望"), + ] + for i, (num, title, desc) in enumerate(items): + col = i % 3 + row = i // 3 + left = Inches(0.5 + col * 4.2) + top = Inches(1.4 + row * 2.6) + add_rect(slide, left, top, Inches(3.9), Inches(2.2), fill=WHITE, line=GRAY_LIGHT) + circ = slide.shapes.add_shape(MSO_SHAPE.OVAL, left + Inches(0.25), top + Inches(0.35), Inches(0.7), Inches(0.7)) + fill_shape(circ, TEAL if row == 0 else DARK) + clear_and_set_text(circ, num, size=14, bold=True, color=WHITE) + add_textbox(slide, left + Inches(1.1), top + Inches(0.4), Inches(2.5), Inches(0.5), title, size=16, bold=True, color=DARK) + add_textbox(slide, left + Inches(0.3), top + Inches(1.2), Inches(3.3), Inches(0.8), desc, size=12, color=GRAY) + add_footer(slide, 2) + + +def slide_pain(prs): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_bg(slide) + add_header_bar(slide, "01 项目背景与痛点", "传统教学型医院管理系统难以支撑「真实 AI + 完整业务」答辩演示") + add_textbox( + slide, + Inches(0.5), + Inches(1.2), + Inches(12.3), + Inches(0.4), + "问题本质:业务割裂、AI 不可信、架构耦合、权限薄弱、依赖脆弱 —— 导致实训演示断链、需求无法验收。", + size=13, + color=GRAY, + ) + data = [ + ["痛点", "典型表现", "对需求/答辩的影响"], + ["业务割裂", "患者、影像、病历、预约缺少统一档案视图", "无法讲清「以患者为中心」闭环"], + ["AI 仅假数据", "随机文案/关键字匹配,无真实检测框与模型链路", "AI 能力不可演示、不可复核"], + ["技术栈陈旧", "Thymeleaf 服务端渲染,前后端耦合", "扩展难、角色权限与 SPA 体验弱"], + ["权限薄弱", "仅 Session 登录判断,缺角色级接口保护", "管理员/医生/影像师诉求无法区分"], + ["不可降级", "外部 AI/LLM 失败则整条链路中断", "答辩现场风险高,验收不稳定"], + ] + add_table( + slide, + Inches(0.45), + Inches(1.7), + Inches(12.4), + Inches(4.8), + 6, + 3, + data, + col_widths=[Inches(2.2), Inches(5.5), Inches(4.7)], + ) + add_footer(slide, 3) + + +def slide_goals(prs): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_bg(slide) + add_header_bar(slide, "01 主题定位与建设目标", "双核心链路 + 五项建设目标,定义「做什么」与「做到什么程度」") + + # two chain cards + add_rect(slide, Inches(0.45), Inches(1.25), Inches(6.1), Inches(1.7), fill=WHITE, line=GRAY_LIGHT) + add_rect_sharp(slide, Inches(0.45), Inches(1.25), Inches(6.1), Inches(0.4), fill=TEAL) + add_textbox(slide, Inches(0.6), Inches(1.3), Inches(5.8), Inches(0.35), "链路一 · 医学影像 AI", size=13, bold=True, color=WHITE) + add_textbox( + slide, + Inches(0.65), + Inches(1.8), + Inches(5.7), + Inches(1.0), + "影像检查 → YOLO 辅助读片 → 结构化诊断报告\n(检测框 · 置信度 · 所见/印象/建议分段)", + size=13, + color=DARK, + ) + + add_rect(slide, Inches(6.8), Inches(1.25), Inches(6.1), Inches(1.7), fill=WHITE, line=GRAY_LIGHT) + add_rect_sharp(slide, Inches(6.8), Inches(1.25), Inches(6.1), Inches(0.4), fill=DARK) + add_textbox(slide, Inches(6.95), Inches(1.3), Inches(5.8), Inches(0.35), "链路二 · 电子病历辅助决策", size=13, bold=True, color=WHITE) + add_textbox( + slide, + Inches(7.0), + Inches(1.8), + Inches(5.7), + Inches(1.0), + "病历录入 → AI 决策(治疗/用药/护理/随访)\n→ 知识库引用(RAG)与冲突/风险提示", + size=13, + color=DARK, + ) + + data = [ + ["建设目标", "需求含义", "可验收表现"], + ["业务闭环", "登录、患者、影像、病历、预约、用户管理全覆盖", "各模块可走通主路径"], + ["AI 可演示", "YOLO 真实检测 + LLM/模板报告与决策", "有框、有置信度、有分段报告"], + ["前后端分离", "Vue SPA + Spring REST + FastAPI AI", "职责清晰,便于分模块讲解"], + ["可离线实训", "默认 H2;AI 不可用时可降级", "无 MySQL/无 Key 仍可演示"], + ["安全可讲", "JWT + 三角色 + BCrypt + 双重路由守卫", "越权访问 403,密码加密"], + ] + add_table( + slide, + Inches(0.45), + Inches(3.2), + Inches(12.4), + Inches(3.5), + 6, + 3, + data, + col_widths=[Inches(2.2), Inches(5.5), Inches(4.7)], + ) + add_footer(slide, 4) + + +def slide_scenes(prs): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_bg(slide) + add_header_bar(slide, "01 应用场景与项目声明", "四类角色场景覆盖日常诊疗演示路径") + scenes = [ + ("影像科", "RADIOLOGIST", "上传胸片等影像 → 一键 AI 诊断 → 查看 YOLO 标注框、置信度与分段报告", TEAL), + ("临床医生", "DOCTOR", "书写病历(高血压/肺炎等关键词)→ 获取治疗、护理、随访建议与知识库来源", DARK), + ("系统管理员", "ADMIN", "配置 LLM、管理知识库、切换/上传 YOLO 权重、管理系统用户", ORANGE), + ("挂号窗口", "业务协同", "预约登记与状态流转:预约 → 确认 → 完成 / 取消 / 未到诊", TEAL_DARK), + ] + for i, (title, role, body, color) in enumerate(scenes): + left = Inches(0.45 + (i % 2) * 6.4) + top = Inches(1.25 + (i // 2) * 2.2) + add_rect(slide, left, top, Inches(6.1), Inches(2.0), fill=WHITE, line=GRAY_LIGHT) + add_rect_sharp(slide, left, top, Inches(0.15), Inches(2.0), fill=color) + add_textbox(slide, left + Inches(0.35), top + Inches(0.25), Inches(3.5), Inches(0.4), title, size=18, bold=True, color=DARK) + badge = add_rect(slide, left + Inches(3.8), top + Inches(0.28), Inches(2.0), Inches(0.35), fill=color) + clear_and_set_text(badge, role, size=10, bold=True, color=WHITE) + add_textbox(slide, left + Inches(0.35), top + Inches(0.85), Inches(5.5), Inches(0.95), body, size=13, color=GRAY) + + # declaration banner + add_rect(slide, Inches(0.45), Inches(5.7), Inches(12.4), Inches(1.1), fill=RGBColor(0xFD, 0xF2, 0xE9), line=ORANGE) + add_textbox( + slide, + Inches(0.7), + Inches(5.9), + Inches(12), + Inches(0.8), + "【项目声明】系统输出仅供教学实训与辅助决策演示,不能替代执业医师的正式诊断与医疗文书。\n涉及真实患者数据与临床部署时,需另行满足医疗信息化、隐私与合规要求。", + size=13, + bold=False, + color=RGBColor(0x8E, 0x44, 0x0A), + ) + add_footer(slide, 5) + + +def slide_roles(prs): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_bg(slide) + add_header_bar(slide, "02 用户角色与诉求", "角色驱动需求:权限边界清晰,才谈得上功能优先级") + data = [ + ["角色", "代码枚举", "核心诉求", "关键能力边界"], + ["系统管理员", "ADMIN", "用户管理、LLM 配置、YOLO 权重、全局运维", "可见 AI 配置/权重/用户管理菜单"], + ["临床医生", "DOCTOR", "患者与病历、辅助决策、预约、查看影像报告", "主业务读写;无系统级配置权"], + ["影像医师", "RADIOLOGIST", "影像登记、上传、触发 AI、审阅标注与报告", "聚焦影像链路与诊断结果"], + ["访客/未登录", "—", "仅可访问登录页", "业务路由强制跳转登录"], + ] + add_table( + slide, + Inches(0.45), + Inches(1.3), + Inches(12.4), + Inches(3.6), + 5, + 4, + data, + col_widths=[Inches(2.0), Inches(2.2), Inches(4.8), Inches(3.4)], + ) + add_textbox(slide, Inches(0.5), Inches(5.15), Inches(12.3), Inches(0.35), "需求推导:前后端双重守卫(路由 meta.roles + 后端 @PreAuthorize)是 F-06 的验收基础。", size=13, bold=True, color=DARK) + # three bullets + points = [ + "管理员配置 AI,保证「可演示」不依赖开发者本机临时改配置", + "医生与影像师分权,贴合医院真实岗位,避免「全能账号」掩盖权限需求", + "未登录零业务数据暴露,是安全类非功能需求的最小闭环", + ] + for i, t in enumerate(points): + y = Inches(5.55 + i * 0.4) + dot = slide.shapes.add_shape(MSO_SHAPE.OVAL, Inches(0.6), y + Inches(0.08), Inches(0.16), Inches(0.16)) + fill_shape(dot, TEAL) + add_textbox(slide, Inches(0.95), y, Inches(11.5), Inches(0.35), t, size=12, color=GRAY) + add_footer(slide, 6) + + +def slide_priority(prs): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_bg(slide) + add_header_bar(slide, "02 功能需求优先级总览", "P0 必须可验收 · P1 重要增强 · P2 体验与健壮性") + + # three columns + blocks = [ + ( + "P0 必须具备", + TEAL, + [ + "F-01 登录/登出/改密(JWT+BCrypt)", + "F-02 患者 CRUD + 搜索分页 + 360°", + "F-03 影像 CRUD + 文件上传", + "F-04 AI 影像诊断(状态机+结果)", + "F-05 病历 CRUD + AI 辅助决策", + "F-06 角色权限(前后端双重)", + ], + ), + ( + "P1 重要增强", + DARK, + [ + "F-07 预约挂号全流程与统计", + "F-08 仪表盘 KPI 与趋势图", + "F-09 AI 多轮对话助手", + "F-10 知识库管理(RAG 引用)", + "F-11 AI/LLM 配置(管理员)", + "F-12 YOLO 权重管理(管理员)", + ], + ), + ( + "P2 体验健壮", + ORANGE, + [ + "F-13 AI 不可用时业务降级", + "F-14 弹窗/抽屉不被布局裁切", + "F-15 报告分段可读展示", + "F-16 路由过渡与看板体验", + "", + "→ 保障答辩现场「不断链」", + ], + ), + ] + for i, (title, color, lines) in enumerate(blocks): + left = Inches(0.4 + i * 4.25) + add_rect(slide, left, Inches(1.25), Inches(4.05), Inches(5.5), fill=WHITE, line=GRAY_LIGHT) + add_rect_sharp(slide, left, Inches(1.25), Inches(4.05), Inches(0.55), fill=color) + add_textbox(slide, left + Inches(0.15), Inches(1.35), Inches(3.7), Inches(0.4), title, size=16, bold=True, color=WHITE, align=PP_ALIGN.CENTER) + for j, line in enumerate(lines): + if not line: + continue + add_textbox(slide, left + Inches(0.2), Inches(2.0 + j * 0.7), Inches(3.65), Inches(0.65), line, size=12, color=DARK) + add_footer(slide, 7) + + +def slide_panorama(prs): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_bg(slide) + add_header_bar(slide, "03 功能全景图", "四大能力域覆盖「身份 → 主数据 → 诊疗 → 智能」") + domains = [ + ("身份与权限", DARK, ["登录 / JWT", "修改密码", "角色菜单", "头像上传"]), + ("业务主数据", TEAL, ["患者管理", "用户管理", "知识库文档", "—"]), + ("诊疗业务", TEAL_DARK, ["影像检查", "电子病历", "预约挂号", "仪表盘统计"]), + ("智能能力", ORANGE, ["YOLO 影像检测", "AI 诊断报告", "EMR 决策 + RAG", "对话 / LLM / 权重"]), + ] + for i, (title, color, items) in enumerate(domains): + left = Inches(0.4 + i * 3.2) + add_rect(slide, left, Inches(1.3), Inches(3.05), Inches(5.3), fill=WHITE, line=GRAY_LIGHT) + add_rect_sharp(slide, left, Inches(1.3), Inches(3.05), Inches(0.7), fill=color) + add_textbox(slide, left, Inches(1.45), Inches(3.05), Inches(0.45), title, size=16, bold=True, color=WHITE, align=PP_ALIGN.CENTER) + for j, it in enumerate(items): + y = Inches(2.3 + j * 1.0) + cell = add_rect(slide, left + Inches(0.2), y, Inches(2.65), Inches(0.75), fill=LIGHT if j % 2 == 0 else GRAY_LIGHT) + clear_and_set_text(cell, it, size=13, bold=False, color=DARK) + add_footer(slide, 8) + + +def slide_imaging(prs): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_bg(slide) + add_header_bar(slide, "03 核心需求① AI 影像诊断(F-03 / F-04)", "异步状态机 + 真实检测结果 + 分段报告 + 可降级") + + # flow boxes + steps = [ + ("新建影像\n上传图片", DARK), + ("PENDING", GRAY), + ("触发 AI\nANALYZING", TEAL), + ("YOLO 检测\n+ 报告生成", TEAL_DARK), + ("COMPLETED\n/ ERROR", ORANGE), + ("前端轮询\n打开报告", DARK), + ] + y = Inches(1.35) + for i, (text, color) in enumerate(steps): + left = Inches(0.35 + i * 2.15) + box = add_rect(slide, left, y, Inches(1.95), Inches(0.95), fill=color) + clear_and_set_text(box, text, size=11, bold=True, color=WHITE) + if i < len(steps) - 1: + add_textbox(slide, left + Inches(1.85), y + Inches(0.25), Inches(0.35), Inches(0.4), "→", size=18, bold=True, color=TEAL) + + add_textbox(slide, Inches(0.45), Inches(2.5), Inches(12), Inches(0.35), "状态机:PENDING → ANALYZING → COMPLETED / ERROR(@Async 避免阻塞 HTTP)", size=12, bold=True, color=DARK) + + data = [ + ["验收维度", "要点", "深度说明"], + ["状态可见", "列表/KPI 可按状态过滤", "过程可观测,避免「点了没反应」"], + ["检测结果", "标注图对比、类别、bbox、置信度", "证明不是假随机文案"], + ["报告结构", "所见 / 印象 / 建议 / 声明分块", "满足 F-15 可读性,便于医师复核叙事"], + ["降级策略", "AI 宕机时本地规则仍出结果", "满足 F-13,答辩现场不断链"], + ["检查类型", "X_RAY / CT / MRI / ULTRASOUND", "覆盖常见影像检查登记场景"], + ] + add_table( + slide, + Inches(0.45), + Inches(2.95), + Inches(12.4), + Inches(3.7), + 6, + 3, + data, + col_widths=[Inches(2.2), Inches(5.0), Inches(5.2)], + ) + add_footer(slide, 9) + + +def slide_emr(prs): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_bg(slide) + add_header_bar(slide, "03 核心需求② 电子病历辅助决策(F-05 / F-10)", "结构化病历 → 决策服务 → 多维建议 + RAG 来源可追溯") + + flow = [ + ("填写并保存病历", "主诉/现病史/诊断/用药等"), + ("Decision 服务", "Spring 编排调用 AI"), + ("RAG + 可选 LLM", "知识检索 / 大模型增强"), + ("失败走模板", "关键词规则保底"), + ("分节展示结果", "Dialog 多维建议"), + ] + for i, (t, s) in enumerate(flow): + left = Inches(0.35 + i * 2.55) + box = add_rect(slide, left, Inches(1.3), Inches(2.4), Inches(1.15), fill=WHITE, line=TEAL) + clear_and_set_text( + box, + [(t, 13, True, DARK), (s, 10, False, GRAY)], + align=PP_ALIGN.CENTER, + ) + if i < len(flow) - 1: + add_textbox(slide, left + Inches(2.25), Inches(1.6), Inches(0.35), Inches(0.4), "→", size=16, bold=True, color=TEAL) + + # output dimensions + outs = [ + ("治疗建议", "方案级条目表"), + ("用药建议", "药物与注意点"), + ("护理建议", "护理要点列表"), + ("随访计划", "复诊与监测"), + ("风险评估", "潜在风险提示"), + ("药物冲突", "相互作用警示"), + ("知识来源", "RAG 引用可点查"), + ] + add_textbox(slide, Inches(0.45), Inches(2.7), Inches(12), Inches(0.35), "决策输出七维(验收时建议逐项点开):", size=13, bold=True, color=DARK) + for i, (t, s) in enumerate(outs): + left = Inches(0.4 + i * 1.82) + c = add_rect(slide, left, Inches(3.15), Inches(1.72), Inches(1.2), fill=TEAL if i % 2 == 0 else DARK) + clear_and_set_text(c, [(t, 12, True, WHITE), (s, 10, False, GRAY_LIGHT)]) + + add_rect(slide, Inches(0.45), Inches(4.6), Inches(12.4), Inches(2.1), fill=WHITE, line=GRAY_LIGHT) + add_textbox( + slide, + Inches(0.65), + Inches(4.75), + Inches(12), + Inches(1.8), + "深度要点\n" + "• 诊断含「高血压 / 糖尿病 / 肺炎 / 结节」等关键词时,模板与 RAG 效果更明显,便于课堂对比。\n" + "• 配置 DeepSeek 等 OpenAI 兼容模型后,由 LLM 增强结构化建议;无 Key 时仍可演示。\n" + "• 知识库文档(F-10)与内置医学 Markdown 片段共同支撑「建议有出处」,区别于纯关键字弹窗。\n" + "• 业务价值:缩短教学场景下「从病历到处置思路」的演示路径,并强调人工复核责任。", + size=12, + color=DARK, + ) + add_footer(slide, 10) + + +def slide_support_features(prs): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_bg(slide) + add_header_bar(slide, "03 配套业务需求", "支撑双 AI 主链路的业务底座与管理能力") + data = [ + ["模块", "需求编号", "关键能力", "验收关注点"], + ["登录与个人中心", "F-01", "JWT 持久化、改密后重登、头像", "未登录拦截 / 已登录跳转仪表盘"], + ["患者管理", "F-02", "分页搜索、CRUD、360° 档案抽屉", "影像/病历/预约关联计数正确"], + ["预约挂号", "F-07", "状态机、按日筛选、统计卡片", "预约→确认→完成/取消/未到诊"], + ["仪表盘", "F-08", "KPI、近 7 日趋势、类型/状态分布", "ECharts 与快捷入口可导览"], + ["AI 助手", "F-09", "多轮对话、Markdown、历史持久化", "历史不随 H2 清空丢失"], + ["知识库 / AI 配置 / YOLO", "F-10~12", "文档、LLM 同步、权重激活", "仅 ADMIN;同步后走大模型"], + ["用户管理", "F-06 配套", "三角色 CRUD、禁停当前账号", "doctor 访问 /users → 403"], + ] + add_table( + slide, + Inches(0.35), + Inches(1.25), + Inches(12.6), + Inches(5.5), + 8, + 4, + data, + col_widths=[Inches(2.6), Inches(1.6), Inches(4.2), Inches(4.2)], + ) + add_footer(slide, 11) + + +def slide_nfr(prs): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_bg(slide) + add_header_bar(slide, "04 非功能需求", "非功能不是「附加项」,而是答辩可演示与可讲解的质量属性") + data = [ + ["类别", "要求", "项目落地", "对应价值"], + ["性能", "诊断不阻塞请求线程", "@Async + 线程池;前端轮询有上限", "长任务可感知、可取消清理"], + ["可用性", "无 MySQL 亦可演示", "H2 内存库 + DataInitializer 种子", "降低环境门槛"], + ["安全", "鉴权、加密、CORS", "Spring Security + JWT + 白名单", "角色隔离可讲可验"], + ["可维护", "统一响应与异常", "Result + GlobalExceptionHandler", "前后端契约稳定"], + ["可扩展", "AI 与业务解耦", "FastAPI 独立进程 + base-url 配置", "视觉/LLM 依赖不污染 Java"], + ["兼容", "大模型可切换", "OpenAI 兼容协议(DeepSeek/Qwen)", "无厂商锁定"], + ] + add_table( + slide, + Inches(0.35), + Inches(1.25), + Inches(12.6), + Inches(5.5), + 7, + 4, + data, + col_widths=[Inches(1.6), Inches(3.2), Inches(4.4), Inches(3.4)], + ) + add_footer(slide, 12) + + +def slide_constraints(prs): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_bg(slide) + add_header_bar(slide, "04 约束、假设与边界", "主动界定范围:避免把「演示系统」误当成生产 HIS/PACS") + data = [ + ["类型", "说明", "答辩话术建议"], + ["定位约束", "面向实训/课程设计/答辩,非生产 HIS 替代", "强调教学闭环与可演示性"], + ["影像假设", "以 JPG/PNG 为主,DICOM 全工作流非重点", "后续扩展方向可答 DICOM"], + ["模型边界", "YOLO 权重与类别为演示级,需医师复核", "输出=辅助,不是确诊"], + ["数据假设", "H2 内存退出丢库;AI 配置/对话存文件可保留", "持久化可切 MySQL profile"], + ["产品边界", "无医保、收费、电子签名、CA 等", "范围控制是需求管理能力"], + ["外部依赖", "LLM 受网络/额度/延迟影响,有超时上限", "模板降级是风险应对设计"], + ] + add_table( + slide, + Inches(0.4), + Inches(1.25), + Inches(12.5), + Inches(4.6), + 7, + 3, + data, + col_widths=[Inches(2.0), Inches(6.0), Inches(4.5)], + ) + add_textbox( + slide, + Inches(0.5), + Inches(6.05), + Inches(12.3), + Inches(0.7), + "设计取舍摘要:默认 H2 降门槛 · AI 独立进程隔离依赖 · JWT 适配分离架构 · LLM 可关保证无 Key 可演 · 报告强制分段避免「墙文本」。", + size=12, + color=GRAY, + ) + add_footer(slide, 13) + + +def slide_arch(prs): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_bg(slide) + add_header_bar(slide, "05 逻辑架构(方案如何满足需求)", "浏览器只访问业务后端;AI 能力集中;失败可降级") + + # layers + layers = [ + (Inches(3.5), Inches(1.25), Inches(6.3), Inches(0.85), "浏览器 Vue 3 SPA · :5173", TEAL, "身份/页面/图表/报告展示"), + (Inches(3.5), Inches(2.5), Inches(6.3), Inches(0.95), "Spring Boot 业务端 · :8080", DARK, "JWT · JPA · 文件 · 异步编排 · 降级"), + ] + for left, top, w, h, title, color, sub in layers: + box = add_rect(slide, left, top, w, h, fill=color) + clear_and_set_text(box, [(title, 14, True, WHITE), (sub, 11, False, GRAY_LIGHT)]) + + # arrow labels + add_textbox(slide, Inches(5.8), Inches(2.1), Inches(2), Inches(0.35), "↓ /api 代理", size=11, color=GRAY, align=PP_ALIGN.CENTER) + + # bottom two + left_box = add_rect(slide, Inches(1.2), Inches(3.9), Inches(4.8), Inches(1.3), fill=WHITE, line=DARK) + clear_and_set_text(left_box, [("H2 / MySQL 业务库", 14, True, DARK), ("患者 · 影像 · 病历 · 预约 · 用户", 11, False, GRAY)]) + right_box = add_rect(slide, Inches(7.3), Inches(3.9), Inches(4.8), Inches(1.3), fill=WHITE, line=TEAL) + clear_and_set_text(right_box, [("FastAPI AI · :8001", 14, True, TEAL_DARK), ("YOLO · RAG · LLM · 权重/配置", 11, False, GRAY)]) + + add_textbox(slide, Inches(3.0), Inches(3.5), Inches(2), Inches(0.35), "↓ 持久化", size=11, color=GRAY, align=PP_ALIGN.CENTER) + add_textbox(slide, Inches(9.0), Inches(3.5), Inches(2.5), Inches(0.35), "↓ HTTP(可选/可降级)", size=11, color=GRAY, align=PP_ALIGN.CENTER) + + # principles + principles = [ + "1. 浏览器不直连 AI 端口(统一鉴权与编排)", + "2. Spring 负责落库、任务状态与降级", + "3. 管理端 LLM 配置单向同步至 FastAPI 运行时", + "4. AI 超时/宕机 → 规则/模板结果保底演示", + ] + for i, p in enumerate(principles): + left = Inches(0.5 + (i % 2) * 6.4) + top = Inches(5.5 + (i // 2) * 0.55) + add_textbox(slide, left, top, Inches(6.2), Inches(0.45), p, size=12, color=DARK) + add_footer(slide, 14) + + +def slide_tech(prs): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_bg(slide) + add_header_bar(slide, "05 技术选型对照", "选型服务于需求:可演示、可分离、可降级、可讲解") + data = [ + ["层级", "技术", "服务的需求", "为何选"], + ["前端", "Vue3 + Vite + Element Plus + ECharts", "F-06/08/14/16 体验与权限路由", "SPA 分离、组件化、图表答辩直观"], + ["业务后端", "Spring Boot 3.3 + Security + JPA", "F-01~07 业务与安全闭环", "成熟权限模型、异步、统一异常"], + ["数据", "H2 默认 / MySQL 可选", "可用性 + 可迁移", "开箱演示;需要持久再切 profile"], + ["AI 服务", "FastAPI + Ultralytics YOLO", "F-04 真实检测", "Python 视觉生态,与 Java 解耦"], + ["决策/报告", "LangChain 生态 + OpenAI 兼容 LLM", "F-05/09/10/11", "RAG+可关 LLM,无 Key 可演"], + ["认证", "JWT + BCrypt", "F-01/06 安全", "无状态,适配前后端分离"], + ] + add_table( + slide, + Inches(0.3), + Inches(1.25), + Inches(12.7), + Inches(5.5), + 7, + 4, + data, + col_widths=[Inches(1.6), Inches(4.0), Inches(3.6), Inches(3.5)], + ) + add_footer(slide, 15) + + +def slide_team(prs): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_bg(slide) + add_header_bar(slide, "06 小组分工(6 人)", "主责清晰 · 需求编号可追溯 · 交付物可检查(姓名可替换)") + data = [["成员", "角色定位", "主责模块", "对应需求", "主要交付物"]] + for row in MEMBERS: + data.append(list(row)) + add_table( + slide, + Inches(0.25), + Inches(1.2), + Inches(12.8), + Inches(4.6), + 7, + 5, + data, + col_widths=[Inches(1.3), Inches(2.4), Inches(3.3), Inches(2.2), Inches(3.6)], + ) + add_textbox( + slide, + Inches(0.4), + Inches(5.95), + Inches(12.5), + Inches(0.85), + "协作关系:A 统筹需求与验收 → B/C 前端联调 → D/E 业务后端与数据 → F 提供 AI 能力;D/E 负责鉴权编排与降级兜底。\n" + "建议联调节奏:先 F-01/06 打通登录权限 → 患者/影像主数据 → AI 诊断 → 病历决策 → 管理端配置与降级演练。", + size=12, + color=GRAY, + ) + add_footer(slide, 16) + + +def slide_acceptance(prs): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_bg(slide) + add_header_bar(slide, "06 验收标准与演示剧本", "把需求变成可执行检查项与 7 步演示路径") + + left_title = add_rect(slide, Inches(0.4), Inches(1.2), Inches(6.1), Inches(0.45), fill=TEAL) + clear_and_set_text(left_title, "验证清单(节选)", size=14, bold=True, color=WHITE) + checks = [ + "三端启动,5173 可登录", + "admin 可见用户管理;doctor1 访问 /users 为 403", + "患者增删改与 360° 档案抽屉正常", + "影像 AI 可达 COMPLETED,报告分段清晰", + "病历 AI 建议含多类内容与来源", + "预约状态可流转", + "改密后需重新登录", + "关闭 AI 服务后诊断仍可降级返回", + ] + for i, c in enumerate(checks): + y = Inches(1.8 + i * 0.55) + box = add_rect(slide, Inches(0.4), y, Inches(0.35), Inches(0.35), fill=TEAL) + clear_and_set_text(box, "✓", size=12, bold=True, color=WHITE) + add_textbox(slide, Inches(0.9), y, Inches(5.5), Inches(0.4), c, size=12, color=DARK) + + right_title = add_rect(slide, Inches(6.8), Inches(1.2), Inches(6.1), Inches(0.45), fill=DARK) + clear_and_set_text(right_title, "推荐演示剧本", size=14, bold=True, color=WHITE) + script = [ + "1. doctor1 / pass123 登录,看仪表盘 KPI", + "2. 患者管理 → 打开 360° 档案", + "3. 影像:上传样例 → AI 诊断 → 原图/标注对比", + "4. 病历:诊断填「高血压/肺炎」→ 看 AI 建议", + "5. 预约:新建并切换状态", + "6. 切换 admin:AI 配置 / YOLO / 用户", + "7. 关闭 AI 服务,对比降级结果", + ] + accounts = [ + ("admin", "admin123", "ADMIN"), + ("doctor1", "pass123", "DOCTOR"), + ("radio1", "radio123", "RADIOLOGIST"), + ] + for i, s in enumerate(script): + add_textbox(slide, Inches(7.0), Inches(1.8 + i * 0.45), Inches(5.8), Inches(0.4), s, size=12, color=DARK) + + add_textbox(slide, Inches(7.0), Inches(5.1), Inches(5.8), Inches(0.3), "内置演示账号", size=12, bold=True, color=DARK) + for i, (u, p, r) in enumerate(accounts): + add_textbox(slide, Inches(7.0), Inches(5.45 + i * 0.35), Inches(5.8), Inches(0.35), f"{u} / {p} · {r}", size=11, color=GRAY) + add_footer(slide, 17) + + +def slide_summary(prs): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + add_bg(slide) + add_header_bar(slide, "总结与展望", "需求闭环已定义清晰;实现路径可演示、可分工、可验收") + + # summary cards + sums = [ + ("需求主线", "双链路:影像 YOLO 诊断 + 病历 RAG/LLM 决策,外加完整业务底座"), + ("质量主线", "P0 可验收、权限可讲、AI 可降级、环境可离线实训"), + ("组织主线", "6 人按前端/后端/AI/统筹切开,需求编号追溯到交付物"), + ] + for i, (t, b) in enumerate(sums): + left = Inches(0.4 + i * 4.25) + add_rect(slide, left, Inches(1.25), Inches(4.05), Inches(1.8), fill=WHITE, line=GRAY_LIGHT) + add_rect_sharp(slide, left, Inches(1.25), Inches(4.05), Inches(0.45), fill=TEAL if i != 2 else DARK) + add_textbox(slide, left + Inches(0.15), Inches(1.32), Inches(3.7), Inches(0.35), t, size=14, bold=True, color=WHITE, align=PP_ALIGN.CENTER) + add_textbox(slide, left + Inches(0.2), Inches(1.9), Inches(3.65), Inches(1.0), b, size=12, color=DARK) + + add_textbox(slide, Inches(0.5), Inches(3.3), Inches(12), Inches(0.4), "后续可扩展(超出当前范围,可作为提问储备)", size=14, bold=True, color=DARK) + exts = [ + ("DICOM 解析\n与序列阅片", TEAL), + ("报告 PDF\n与电子签收", DARK), + ("细粒度权限\n与操作审计", TEAL_DARK), + ("向量库增强\nRAG", ORANGE), + ("Docker Compose\n一键拉起", DARK), + ("接口自动化\n与 CI", TEAL), + ] + for i, (t, c) in enumerate(exts): + left = Inches(0.4 + i * 2.15) + box = add_rect(slide, left, Inches(3.85), Inches(2.05), Inches(1.35), fill=c) + clear_and_set_text(box, t, size=12, bold=True, color=WHITE) + + add_rect(slide, Inches(0.4), Inches(5.5), Inches(12.5), Inches(1.2), fill=DARK) + add_textbox( + slide, + Inches(0.7), + Inches(5.7), + Inches(12), + Inches(0.9), + "谢谢各位老师! 欢迎提问\nSmart Hospital · 需求答辩 · 组员 A–F · 输出仅供教学演示", + size=16, + bold=True, + color=WHITE, + align=PP_ALIGN.CENTER, + ) + add_footer(slide, 18) + + +def main(): + prs = Presentation() + prs.slide_width = SLIDE_W + prs.slide_height = SLIDE_H + + slide_cover(prs) + slide_toc(prs) + slide_pain(prs) + slide_goals(prs) + slide_scenes(prs) + slide_roles(prs) + slide_priority(prs) + slide_panorama(prs) + slide_imaging(prs) + slide_emr(prs) + slide_support_features(prs) + slide_nfr(prs) + slide_constraints(prs) + slide_arch(prs) + slide_tech(prs) + slide_team(prs) + slide_acceptance(prs) + slide_summary(prs) + + OUT_PATH.parent.mkdir(parents=True, exist_ok=True) + prs.save(str(OUT_PATH)) + print(f"OK: {OUT_PATH}") + print(f"slides: {len(prs.slides)}") + + +if __name__ == "__main__": + main() diff --git a/xuqiu/智慧医院-需求答辩PPT.pptx b/xuqiu/智慧医院-需求答辩PPT.pptx new file mode 100644 index 0000000..1acfd04 Binary files /dev/null and b/xuqiu/智慧医院-需求答辩PPT.pptx differ diff --git a/xuqiu/项目详细文档.md b/xuqiu/项目详细文档.md new file mode 100644 index 0000000..b5432d0 --- /dev/null +++ b/xuqiu/项目详细文档.md @@ -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` + `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`,`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`(可能过时) | + +--- + +**文档结束** diff --git a/项目详细文档.md b/项目详细文档.md new file mode 100644 index 0000000..b5432d0 --- /dev/null +++ b/项目详细文档.md @@ -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` + `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`,`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`(可能过时) | + +--- + +**文档结束**