26-7-31-1

This commit is contained in:
shuai
2026-07-31 12:27:41 +08:00
commit 9b8b8b57b3
142 changed files with 22408 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
DEMO_MODE=auto
+22
View File
@@ -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
+213
View File
@@ -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 完整度**方面都存在明显扩展空间。
**非常适合作为二次开发或课程改造的起点。**
+151
View File
@@ -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<T>` + `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. 右上角用户菜单 → 修改密码 → 成功后需重新登录
+17
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
.venv/
__pycache__/
*.py[cod]
.env
data/
*.jpg
*.jpeg
*.png
!samples/.gitkeep
.idea/
.vscode/
+75
View File
@@ -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`,由后端转发到本服务。
## 声明
输出仅供教学实训与辅助决策演示,**不能替代执业医师诊断**。
+1
View File
@@ -0,0 +1 @@
"""Smart Hospital AI Service."""
+1
View File
@@ -0,0 +1 @@
"""API routers."""
+26
View File
@@ -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(),
)
+89
View File
@@ -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")
+70
View File
@@ -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,
}
+49
View File
@@ -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()}
+21
View File
@@ -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)
+135
View File
@@ -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(),
}
+63
View File
@@ -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()
@@ -0,0 +1,19 @@
# 胆囊结石辅助管理要点
category: 消化外科
## 表现
右上腹痛、油腻饮食后加重,可伴恶心;超声是首选检查。
## 处理
- 无症状结石可观察
- 症状性结石评估腹腔镜胆囊切除指征
- 合并胆管炎/胰腺炎需紧急处理
## 护理与饮食
- 低脂饮食
- 观察腹痛、黄疸、发热
- 术后早期活动
## 注意
实训演示用,非临床处方依据。
+24
View File
@@ -0,0 +1,24 @@
# 2 型糖尿病辅助管理要点
category: 内分泌
## 诊断要点
空腹血糖、OGTT 或 HbA1c 达到诊断阈值;需排除 1 型及其他特殊类型。
## 综合管理
- 医学营养治疗与运动
- 血糖自我监测
- 个体化降糖药物(如二甲双胍等,注意禁忌)
- 血压、血脂与抗血小板综合干预
## 并发症筛查
- 视网膜病变、肾病、神经病变、足病
- 心血管风险评估
## 护理与随访
- 低血糖识别与处理
- 足部护理
- 定期复查血糖与 HbA1c
## 注意
本资料用于教学演示,临床决策需结合指南与患者情况。
+20
View File
@@ -0,0 +1,20 @@
# 骨折影像与处置要点(示意)
category: 骨科
## 影像
- X 线是基础检查;复杂部位可 CT/MRI。
- 描述骨折部位、类型、移位、关节受累。
## 处理原则
- 复位、固定、功能锻炼
- 开放伤注意清创抗感染
- 评估血管神经损伤
## 护理与随访
- 患肢肿胀、血运、感觉观察
- 疼痛管理与防深静脉血栓
- 按医嘱复查 X 光评估愈合
## 注意
仅供实训演示,不作为临床操作依据。
+17
View File
@@ -0,0 +1,17 @@
# 电子病历与辅助决策书写规范(示意)
category: 病历质控
## 病历要素
主诉、现病史、体格检查、辅助检查、诊断、治疗计划、用药、随访。
## AI 辅助使用原则
- AI 输出仅供参考,必须经医师审核修改后入档。
- 引用知识库内容时注明来源类别。
- 避免将模型幻觉内容写入正式病历。
## 护理建议常见结构
病情观察、用药护理、生活指导、心理支持、健康宣教。
## 随访计划
复诊时间、复查项目、危险症状返院指征。
+24
View File
@@ -0,0 +1,24 @@
# 原发性高血压辅助管理要点
category: 心血管
## 诊断
非同日三次诊室血压 ≥140/90 mmHg 可诊断高血压;鼓励家庭血压与动态血压监测。
## 非药物治疗
- 限盐(<5 g/日)、DASH 饮食
- 规律有氧运动、控制体重
- 戒烟限酒、减少精神压力
## 药物治疗(示意)
- 常用类别:ACEI/ARB、CCB、利尿剂、β 受体阻滞剂等。
- 合并糖尿病、慢性肾病等需个体化选药。
- 注意体位性低血压与电解质紊乱。
## 护理与随访
- 规范测量并记录血压
- 提高服药依从性宣教
- 评估靶器官损害,定期复诊
## 注意
用药方案必须由执业医师开具,本文仅供实训演示。
+22
View File
@@ -0,0 +1,22 @@
# 颈椎/腰椎间盘突出 MRI 辅助解读要点
category: 骨科/影像
## 常见表现
- 椎间盘信号改变、突出压迫硬膜囊或神经根
- 可伴椎管狭窄、黄韧带肥厚
## 临床相关
- 颈肩痛、上肢放射痛、腰痛、下肢放射痛、麻木
- 需与脊髓病、肿瘤、感染鉴别
## 治疗路径(示意)
- 多数可先保守:休息、理疗、药物、功能锻炼
- 神经功能障碍加重或马尾症状需紧急评估手术指征
## 随访
- 症状变化记录
- 必要时复查 MRI
## 注意
教学演示资料,诊断以影像科与临床医师为准。
+23
View File
@@ -0,0 +1,23 @@
# 社区获得性肺炎辅助诊疗要点
category: 呼吸内科
## 概述
社区获得性肺炎(CAP)是指在医院外罹患的肺实质感染,常见症状包括发热、咳嗽、咳痰、胸痛与呼吸困难。
## 影像表现
- X 线/CT:片状、斑片状浸润影或实变,可伴空气支气管征。
- 需与肺结核、肿瘤、肺水肿等鉴别。
## 治疗原则
- 评估 CURB-65 或 PSI 严重程度分层。
- 经验性抗感染覆盖常见病原(肺炎链球菌等),并根据培养结果调整。
- 支持治疗:氧疗、补液、退热、止咳化痰。
## 护理与随访
- 监测体温、呼吸频率、血氧饱和度。
- 鼓励有效咳嗽与体位引流。
- 治疗后 3–5 天评估疗效;必要时复查胸片。
## 注意
本资料仅供教学演示与辅助决策参考,不能替代临床指南与医师判断。
@@ -0,0 +1,23 @@
# 肺结节随访与管理要点
category: 呼吸/胸外
## 定义
肺结节通常指直径 ≤3 cm 的圆形或类圆形 dens 灶,需结合大小、形态、密度及随访变化综合判断。
## 影像关注点
- 大小与倍增时间
- 边缘(毛刺、分叶)、钙化、空泡、胸膜牵拉
- 实性 / 部分实性 / 磨玻璃
## 管理建议(示意)
- 微小结节:定期 CT 随访。
- 可疑恶性特征:建议专科评估,必要时 PET、活检或多学科讨论。
- 戒烟是重要干预措施。
## 护理与宣教
- 缓解患者焦虑,说明随访意义。
- 出现咯血、胸痛、进行性气促及时就诊。
## 注意
本资料仅供教学演示,实际随访间隔需按最新指南与个体情况确定。
+75
View File
@@ -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,
)
+27
View File
@@ -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",
]
+108
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""AI service implementations."""
+142
View File
@@ -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
+84
View File
@@ -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",
}
@@ -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},
}
+242
View File
@@ -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
+417
View File
@@ -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"(?<![.\d])\s*([((]?\d+[)).、])\s*", r"\n\1", s)
# 中文段落分隔:句号/分号后跟新意时换行(保留较短从句)
s = re.sub(r"([。;])\s*", r"\1\n", s)
lines = [ln.strip() for ln in s.splitlines() if ln.strip()]
# 合并过碎的短行(如单独标点)
merged: list[str] = []
for ln in lines:
if merged and len(ln) <= 2 and not re.match(r"^[((]?\d+", ln):
merged[-1] = merged[-1] + ln
else:
merged.append(ln)
return "\n".join(merged)
def _normalize_recommendations(text: str) -> 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",
)
+246
View File
@@ -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()
+345
View File
@@ -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
+23
View File
@@ -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
+142
View File
@@ -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:遵循原仓库许可证,仅作科研/教学
- 仿真图:项目内生成,无患者隐私
- **禁止将本素材与系统输出用于真实诊疗**
+1
View File
@@ -0,0 +1 @@
[ ]
+10
View File
@@ -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"
}
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.env.local
.vite
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>智慧医院 AI 影像诊断与电子病历辅助决策系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+2674
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

+209
View File
@@ -0,0 +1,209 @@
<template>
<!--
仅在「登录页」与「主布局壳」之间过渡。
key 不能用完整 path,否则子路由切换会整页卸载侧边栏。
-->
<router-view v-slot="{ Component, route }">
<transition name="shell" mode="out-in">
<component
:is="Component"
:key="route.name === 'Login' ? 'login' : 'main-shell'"
/>
</transition>
</router-view>
</template>
<script setup></script>
<style>
html,
body,
#app {
height: 100%;
margin: 0;
overflow: hidden; /* 由布局内部各自滚动 */
font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
background: #e8f0f7;
color: #0f172a;
-webkit-font-smoothing: antialiased;
}
/* 登录 ↔ 主壳:柔和淡入,不位移,避免布局抖动 */
.shell-enter-active,
.shell-leave-active {
transition: opacity 0.32s ease;
}
.shell-enter-from,
.shell-leave-to {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.shell-enter-active,
.shell-leave-active {
transition: none !important;
}
.shell-enter-from,
.shell-leave-to {
opacity: 1;
}
}
*,
*::before,
*::after {
box-sizing: border-box;
}
a {
color: inherit;
text-decoration: none;
}
/* Element Plus 微调 */
.el-card {
--el-card-border-color: transparent;
}
.el-table {
--el-table-header-bg-color: #f8fafc;
--el-table-header-text-color: #475569;
}
.el-dialog__header {
margin-right: 0;
font-weight: 700;
color: #0f172a;
flex-shrink: 0;
}
.el-dialog {
border-radius: 14px;
overflow: hidden;
}
/*
* 仅作用于 Dialog 的 overlay(不要影响 Drawer)。
* Element Plus:Dialog 用 .el-overlay-dialog,Drawer 用 .el-drawer。
*/
/*
* 遮罩内垂直居中:用 margin:auto 而不是 align-items:center,
* 避免弹窗高于视口时顶部被裁切且无法滚到开头。
*/
.el-overlay-dialog {
display: flex !important;
align-items: flex-start !important;
justify-content: center !important;
overflow: auto;
padding: 24px 12px;
box-sizing: border-box;
}
.el-overlay-dialog .el-dialog {
margin: auto !important;
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 仅 Dialog body,避免误伤 Drawer body */
.el-overlay-dialog .el-dialog__body {
overflow-x: hidden !important;
overflow-y: auto;
max-width: 100%;
flex: 1 1 auto;
min-height: 0;
}
.el-overlay-dialog .el-dialog__header,
.el-overlay-dialog .el-dialog__footer {
flex-shrink: 0;
}
/* Drawer:保证全高贴右,内容在 body 内滚动(防止被布局 overflow 裁切) */
.el-drawer.rtl,
.el-drawer.ltr {
height: 100% !important;
max-height: 100vh !important;
}
.el-drawer__body {
overflow-x: hidden;
overflow-y: auto;
box-sizing: border-box;
}
/* 表单类弹窗:整窗限高,仅 body 滚动,页脚始终可见 */
.el-dialog.form-dialog {
max-height: min(88vh, 760px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.el-dialog.form-dialog .el-dialog__header {
padding: 16px 24px 12px;
}
.el-dialog.form-dialog .el-dialog__body {
/* 左右内边距一致,避免表单视觉偏左/偏右 */
padding: 8px 24px 12px;
max-height: none;
overflow-x: hidden !important;
overflow-y: auto;
box-sizing: border-box;
/* 有无滚动条都预留同一侧空间,避免右侧空白忽大忽小 */
scrollbar-gutter: stable;
}
.el-dialog.form-dialog .el-dialog__footer {
padding: 12px 24px 16px;
}
/* 报告类宽弹窗同样限高,避免长报告顶出视口 */
.el-dialog.result-dialog {
max-height: min(90vh, 900px);
width: min(920px, calc(100vw - 32px)) !important;
display: flex;
flex-direction: column;
overflow: hidden;
}
.el-dialog.result-dialog .el-dialog__body {
padding: 8px 20px 12px;
max-height: none;
overflow-x: hidden !important;
overflow-y: auto;
box-sizing: border-box;
flex: 1 1 auto;
min-height: 0;
}
.el-dialog .el-form {
max-width: 100%;
box-sizing: border-box;
}
/*
* 勿再清掉 el-row 的负 margin:
* gutter 依赖 row 负边距 + col 内边距成对出现,只清一边会导致左右空白不均。
* 横向溢出改由 body 的 overflow-x + box-sizing 约束。
*/
.el-dialog .el-slider {
max-width: 100%;
box-sizing: border-box;
}
.el-pagination {
--el-pagination-button-bg-color: transparent;
}
/* 业务卡片半透明叠在背景图上更清晰 */
.page .panel.el-card,
.dashboard .stat-card.el-card,
.dashboard .chart-card.el-card {
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(6px);
}
</style>
+41
View File
@@ -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')
+11
View File
@@ -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}`)
+13
View File
@@ -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' }
})
}
+9
View File
@@ -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
+10
View File
@@ -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
+8
View File
@@ -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}`)
+4
View File
@@ -0,0 +1,4 @@
import request from '@/utils/request'
export const getOverview = () => request.get('/stats/overview')
export const fetchOverview = getOverview
+16
View File
@@ -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' }
})
}
+626
View File
@@ -0,0 +1,626 @@
<template>
<el-container class="layout">
<el-aside :width="collapsed ? '72px' : '220px'" class="aside">
<div class="aside-bg" />
<div class="aside-inner">
<div class="brand" @click="$router.push('/dashboard')">
<span class="brand-icon">
<el-icon :size="22"><OfficeBuilding /></el-icon>
</span>
<div v-show="!collapsed" class="brand-text">
<div class="brand-title">智慧医院</div>
<div class="brand-sub">医院信息管理平台</div>
</div>
</div>
<div class="aside-scroll">
<el-menu
:default-active="activeRoute"
:collapse="collapsed"
:collapse-transition="false"
router
class="side-menu"
background-color="transparent"
text-color="rgba(255,255,255,0.88)"
active-text-color="#fff"
>
<el-menu-item index="/dashboard">
<el-icon><Odometer /></el-icon>
<span>仪表盘</span>
</el-menu-item>
<el-menu-item index="/patients">
<el-icon><User /></el-icon>
<span>患者管理</span>
</el-menu-item>
<el-menu-item index="/imaging">
<el-icon><PictureFilled /></el-icon>
<span>影像诊断</span>
</el-menu-item>
<el-menu-item index="/emrs">
<el-icon><Document /></el-icon>
<span>电子病历</span>
</el-menu-item>
<el-menu-item index="/appointments">
<el-icon><Calendar /></el-icon>
<span>预约挂号</span>
</el-menu-item>
<el-menu-item index="/ai-assistant">
<el-icon><ChatDotRound /></el-icon>
<span>AI 助手</span>
</el-menu-item>
<el-menu-item index="/ai-knowledge">
<el-icon><Collection /></el-icon>
<span>知识库</span>
</el-menu-item>
<el-menu-item v-if="userStore.isAdmin" index="/ai-settings">
<el-icon><Cpu /></el-icon>
<span>AI 配置</span>
</el-menu-item>
<el-menu-item v-if="userStore.isAdmin" index="/ai-yolo">
<el-icon><PictureFilled /></el-icon>
<span>YOLO 权重</span>
</el-menu-item>
<el-menu-item v-if="userStore.isAdmin" index="/users">
<el-icon><Setting /></el-icon>
<span>用户管理</span>
</el-menu-item>
</el-menu>
</div>
<div class="aside-footer">
<el-button text class="collapse-btn" @click="collapsed = !collapsed">
<el-icon><Fold v-if="!collapsed" /><Expand v-else /></el-icon>
<span v-if="!collapsed">收起菜单</span>
</el-button>
</div>
</div>
</el-aside>
<el-container class="main-wrap">
<el-header class="header">
<div class="header-left">
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/dashboard' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ pageTitle }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<span class="user-chip">
<el-avatar :size="32" class="avatar" :src="avatarSrc || undefined">
{{ avatarText }}
</el-avatar>
<span class="user-meta">
<span class="name">{{ userStore.displayName || '用户' }}</span>
<el-tag size="small" effect="plain" :type="roleTag">{{ roleText }}</el-tag>
</span>
<el-icon class="caret"><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item disabled>
{{ userStore.userInfo?.username || '' }}
</el-dropdown-item>
<el-dropdown-item command="avatar">更换头像</el-dropdown-item>
<el-dropdown-item command="password">修改密码</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main ref="mainRef" class="main">
<div class="main-content">
<router-view v-slot="{ Component, route: viewRoute }">
<transition
name="page"
mode="out-in"
@before-leave="onPageBeforeLeave"
@after-enter="onPageAfterEnter"
>
<!-- 用包裹层做 key/过渡,兼容多根页面组件,不污染业务根节点 -->
<div v-if="Component" :key="viewRoute.path" class="page-view">
<component :is="Component" />
</div>
</transition>
</router-view>
</div>
</el-main>
</el-container>
<el-dialog v-model="pwdVisible" title="修改密码" width="420px" destroy-on-close align-center append-to-body>
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="90px">
<el-form-item label="原密码" prop="oldPassword">
<el-input v-model="pwdForm.oldPassword" type="password" show-password autocomplete="off" />
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="pwdForm.newPassword" type="password" show-password autocomplete="off" />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="pwdForm.confirmPassword" type="password" show-password autocomplete="off" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="pwdVisible = false">取 消</el-button>
<el-button type="primary" :loading="pwdSaving" @click="submitPassword">确 定</el-button>
</template>
</el-dialog>
<el-dialog v-model="avatarVisible" title="更换头像" width="420px" destroy-on-close align-center append-to-body>
<div class="avatar-panel">
<el-avatar :size="88" class="avatar preview" :src="avatarPreview || avatarSrc || undefined">
{{ avatarText }}
</el-avatar>
<el-upload
:show-file-list="false"
:auto-upload="false"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
:on-change="onAvatarPick"
>
<el-button type="primary" plain>选择图片</el-button>
</el-upload>
<div class="avatar-tip">支持 jpg / png / gif / webp,不超过 2MB</div>
</div>
<template #footer>
<el-button @click="avatarVisible = false">取 消</el-button>
<el-button type="primary" :loading="avatarSaving" :disabled="!avatarFile" @click="submitAvatar">
上 传
</el-button>
</template>
</el-dialog>
</el-container>
</template>
<script setup>
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { roleLabel, roleTagType } from '@/utils/labels'
import { changePassword, uploadAvatar } from '@/api/auth'
import {
User,
Odometer,
PictureFilled,
Document,
Setting,
Fold,
Expand,
ArrowDown,
Calendar,
OfficeBuilding,
ChatDotRound,
Cpu,
Collection
} from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const collapsed = ref(false)
const mainRef = ref()
const titleMap = {
'/dashboard': '仪表盘',
'/patients': '患者管理',
'/imaging': '影像诊断',
'/emrs': '电子病历',
'/appointments': '预约挂号',
'/ai-assistant': 'AI 助手',
'/ai-settings': 'AI 配置',
'/ai-knowledge': '知识库',
'/ai-yolo': 'YOLO 权重',
'/users': '用户管理',
'/403': '无权访问'
}
const activeRoute = computed(() => route.path)
const pageTitle = computed(() => route.meta?.title || titleMap[route.path] || '工作台')
/** 切换页面时滚回主内容顶部,避免新页停留在旧滚动位置 */
const scrollMainToTop = () => {
const el = mainRef.value?.$el || mainRef.value
if (el && typeof el.scrollTo === 'function') {
el.scrollTo({ top: 0, behavior: 'auto' })
} else if (el) {
el.scrollTop = 0
}
}
const onPageBeforeLeave = () => {
scrollMainToTop()
}
/** 进入后触发 resize,让 ECharts 等图表按新尺寸重绘 */
const onPageAfterEnter = async () => {
await nextTick()
window.dispatchEvent(new Event('resize'))
}
watch(
() => route.path,
() => {
scrollMainToTop()
}
)
const roleText = computed(() => roleLabel(userStore.role))
const roleTag = computed(() => roleTagType(userStore.role))
const avatarText = computed(() => (userStore.displayName || 'U').slice(0, 1))
const avatarSrc = computed(() => userStore.userInfo?.avatar || '')
const pwdVisible = ref(false)
const pwdSaving = ref(false)
const pwdFormRef = ref()
const pwdForm = reactive({ oldPassword: '', newPassword: '', confirmPassword: '' })
const pwdRules = {
oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '新密码至少 6 位', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{
validator: (_, value, callback) => {
if (value !== pwdForm.newPassword) callback(new Error('两次输入的密码不一致'))
else callback()
},
trigger: 'blur'
}
]
}
const avatarVisible = ref(false)
const avatarSaving = ref(false)
const avatarFile = ref(null)
const avatarPreview = ref('')
const handleCommand = async (cmd) => {
if (cmd === 'avatar') {
avatarFile.value = null
avatarPreview.value = ''
avatarVisible.value = true
return
}
if (cmd === 'password') {
Object.assign(pwdForm, { oldPassword: '', newPassword: '', confirmPassword: '' })
pwdVisible.value = true
return
}
if (cmd === 'logout') {
await ElMessageBox.confirm('确认退出登录?', '提示', { type: 'warning' })
userStore.reset()
router.push('/login')
}
}
const onAvatarPick = (file) => {
const raw = file.raw
if (!raw) return
if (!raw.type?.startsWith('image/')) {
ElMessage.warning('请选择图片文件')
return
}
if (raw.size > 2 * 1024 * 1024) {
ElMessage.warning('图片不能超过 2MB')
return
}
avatarFile.value = raw
if (avatarPreview.value) URL.revokeObjectURL(avatarPreview.value)
avatarPreview.value = URL.createObjectURL(raw)
}
const submitAvatar = async () => {
if (!avatarFile.value) return
avatarSaving.value = true
try {
const user = await uploadAvatar(avatarFile.value)
userStore.setUserInfo(user)
ElMessage.success('头像已更新')
avatarVisible.value = false
} finally {
avatarSaving.value = false
}
}
const submitPassword = async () => {
await pwdFormRef.value.validate()
pwdSaving.value = true
try {
await changePassword({
oldPassword: pwdForm.oldPassword,
newPassword: pwdForm.newPassword
})
ElMessage.success('密码已修改,请重新登录')
pwdVisible.value = false
userStore.reset()
router.push('/login')
} finally {
pwdSaving.value = false
}
}
</script>
<style scoped>
.layout {
height: 100vh;
max-height: 100vh;
overflow: hidden;
background: #e8f0f7;
}
.aside {
position: relative;
height: 100%;
overflow: hidden;
color: #fff;
transition: width 0.2s ease;
box-shadow: 2px 0 16px rgba(15, 61, 102, 0.2);
z-index: 20;
flex-shrink: 0;
}
.aside-bg {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgba(15, 61, 102, 0.92) 0%, rgba(26, 106, 162, 0.88) 55%, rgba(31, 135, 197, 0.9) 100%),
url('/images/aside-bg.jpg') center / cover no-repeat;
z-index: 0;
}
.aside-inner {
position: relative;
z-index: 1;
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
padding: 18px 16px 14px;
cursor: pointer;
border-bottom: 1px solid rgba(255, 255, 255, 0.14);
flex-shrink: 0;
}
.brand-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.16);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.brand-title {
font-size: 16px;
font-weight: 700;
letter-spacing: 0.5px;
}
.brand-sub {
font-size: 12px;
opacity: 0.78;
margin-top: 2px;
}
.aside-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 10px 8px;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.35) transparent;
}
.aside-scroll::-webkit-scrollbar {
width: 5px;
}
.aside-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
.side-menu {
border-right: none !important;
}
.side-menu :deep(.el-menu-item) {
border-radius: 10px;
margin: 4px 0;
height: 46px;
transition: background-color 0.2s ease, color 0.2s ease, padding 0.2s ease;
}
.side-menu :deep(.el-menu-item.is-active) {
background: rgba(255, 255, 255, 0.2) !important;
font-weight: 600;
}
.side-menu :deep(.el-menu-item:hover) {
background: rgba(255, 255, 255, 0.12) !important;
}
.aside-footer {
padding: 10px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.14);
flex-shrink: 0;
}
.collapse-btn {
width: 100%;
color: rgba(255, 255, 255, 0.9) !important;
justify-content: flex-start;
}
.main-wrap {
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
height: 64px;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(8px);
border-bottom: 1px solid #e6edf5;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
z-index: 10;
box-shadow: 0 1px 8px rgba(15, 61, 102, 0.06);
}
.header-right {
display: flex;
align-items: center;
}
.user-chip {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
color: #334155;
}
.avatar {
background: linear-gradient(135deg, #1e6091, #38bdf8);
color: #fff;
font-weight: 700;
}
.user-meta {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
line-height: 1.2;
}
.user-meta .name {
font-size: 14px;
font-weight: 600;
}
.caret {
color: #94a3b8;
}
.main {
position: relative;
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
background:
linear-gradient(160deg, rgba(240, 247, 252, 0.9) 0%, rgba(232, 242, 250, 0.88) 45%, rgba(255, 255, 255, 0.93) 100%),
url('/images/main-bg.jpg') center / cover no-repeat;
background-attachment: local;
scrollbar-width: thin;
scrollbar-color: #94a3b8 transparent;
}
.main::-webkit-scrollbar {
width: 8px;
}
.main::-webkit-scrollbar-thumb {
background: #94a3b8;
border-radius: 6px;
}
.main-content {
position: relative;
z-index: 1;
padding: 20px 24px 32px;
min-height: 100%;
/* 为 out-in 过渡预留稳定高度,减少闪白 */
isolation: isolate;
}
/* 页面视图根节点:保证 transition 作用在整页内容上 */
.page-view {
display: block;
width: 100%;
min-height: 40vh;
will-change: opacity, transform;
}
/* ---------- 业务页切换:轻量上浮淡入 / 淡出 ---------- */
.page-enter-active {
transition:
opacity 0.28s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.page-leave-active {
transition:
opacity 0.16s ease,
transform 0.16s ease;
}
.page-enter-from {
opacity: 0;
transform: translateY(12px);
}
.page-leave-to {
opacity: 0;
transform: translateY(-6px);
}
/* 面包屑标题轻微过渡(跟随路由文案变化) */
.header-left :deep(.el-breadcrumb__inner) {
transition: color 0.2s ease;
}
.avatar-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 8px 0 4px;
}
.avatar-panel .preview {
font-size: 28px;
}
.avatar-tip {
font-size: 12px;
color: #94a3b8;
}
/* 系统偏好:减少动效时仅保留瞬时切换 */
@media (prefers-reduced-motion: reduce) {
.page-enter-active,
.page-leave-active {
transition: none !important;
}
.page-enter-from,
.page-leave-to {
opacity: 1;
transform: none;
}
.page-view {
will-change: auto;
}
}
@media (max-width: 900px) {
.user-meta {
display: none;
}
}
</style>
+14
View File
@@ -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')
+108
View File
@@ -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
+41
View File
@@ -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)
}
}
})
+73
View File
@@ -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)
}
+24
View File
@@ -0,0 +1,24 @@
import { marked } from 'marked'
import DOMPurify from 'dompurify'
marked.setOptions({
gfm: true,
breaks: true // 单个换行也转 <br>
})
/**
* 将 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br/>'))
}
}
+59
View File
@@ -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
+898
View File
@@ -0,0 +1,898 @@
<template>
<div class="assistant-page">
<div class="shell">
<!-- 顶栏 -->
<div class="topbar">
<div class="topbar-left">
<div class="brand-mark">
<el-icon :size="20"><ChatDotRound /></el-icon>
</div>
<div>
<div class="title">AI 助手</div>
<div class="sub">医疗知识问答 · 支持纯模型与知识库增强</div>
</div>
</div>
<div class="topbar-right">
<el-segmented v-model="mode" :options="modeOptions" />
<el-tooltip content="刷新历史" placement="top">
<el-button :icon="Refresh" circle @click="loadHistory" :loading="historyLoading" />
</el-tooltip>
<el-tooltip content="清空历史" placement="top">
<el-button :icon="Delete" circle type="danger" plain @click="onClear" />
</el-tooltip>
</div>
</div>
<!-- 模式说明条 -->
<div class="mode-bar" :class="mode === 'RAG' ? 'rag' : 'chat'">
<el-icon><component :is="mode === 'RAG' ? Collection : Cpu" /></el-icon>
<span v-if="mode === 'RAG'">
<b>RAG 知识库</b>:先检索本地知识,再生成回答;未配置 API 时展示检索摘录
</span>
<span v-else>
<b>纯 AI 问答</b>:直接调用大模型(需管理员配置并启用 API)
</span>
<span class="msg-count" v-if="pairCount">共 {{ pairCount }} 轮对话</span>
</div>
<!-- 对话区 -->
<div ref="listRef" class="chat-list" v-loading="historyLoading">
<!-- 欢迎 / 空状态 -->
<div v-if="!messages.length && !sending" class="welcome">
<div class="welcome-icon">
<el-icon :size="36"><ChatLineRound /></el-icon>
</div>
<h3>您好,我是医院 AI 助手</h3>
<p>可咨询用药要点、检查须知、就诊流程等。以下问题可一键试问:</p>
<div class="presets">
<button
v-for="p in presets"
:key="p"
type="button"
class="preset"
@click="askPreset(p)"
>
{{ p }}
</button>
</div>
</div>
<template v-else>
<div
v-for="(m, idx) in messages"
:key="m.id || idx"
class="msg-row"
:class="m.role"
>
<div class="avatar" :class="m.role">
<el-icon v-if="m.role === 'assistant'"><Cpu /></el-icon>
<span v-else>{{ userInitial }}</span>
</div>
<div class="bubble-wrap">
<div class="meta">
<span class="who">{{ m.role === 'user' ? '我' : 'AI 助手' }}</span>
<el-tag v-if="m.mode" size="small" effect="plain" round>
{{ m.mode === 'RAG' ? 'RAG' : '纯 AI' }}
</el-tag>
<el-tag v-if="m.usedLlm" size="small" type="success" effect="plain" round>LLM</el-tag>
<el-tag v-if="m.fallback" size="small" type="warning" effect="plain" round>本地降级</el-tag>
<span v-if="m.time" class="time">{{ m.time }}</span>
</div>
<div class="bubble" :class="m.role">
<!-- 用户消息纯文本;助手消息 Markdown 渲染 -->
<div
v-if="m.role === 'assistant'"
class="content md-body"
v-html="renderMarkdown(m.content)"
/>
<div v-else class="content">{{ m.content }}</div>
<div v-if="m.sources?.length" class="sources">
<div class="sources-head" @click="m.sourcesOpen = !m.sourcesOpen">
<el-icon><Document /></el-icon>
<span>参考资料({{ m.sources.length }})</span>
<el-icon class="chev">
<ArrowUp v-if="m.sourcesOpen" />
<ArrowDown v-else />
</el-icon>
</div>
<div v-show="m.sourcesOpen !== false" class="sources-body">
<div v-for="(s, si) in m.sources" :key="s.id || si" class="source-card">
<div class="source-title">
<span class="idx">{{ si + 1 }}</span>
<b>{{ s.title || '未命名' }}</b>
<el-tag v-if="s.category" size="small" type="info" effect="plain">{{ s.category }}</el-tag>
</div>
<p>{{ s.snippet }}</p>
</div>
</div>
</div>
<div v-if="m.role === 'assistant'" class="bubble-actions">
<el-button link type="primary" size="small" @click="copyText(m.content)">
<el-icon><CopyDocument /></el-icon>
复制
</el-button>
<el-button
v-if="m.error && m.retryQuestion"
link
type="warning"
size="small"
@click="retry(m)"
>
重试
</el-button>
</div>
</div>
</div>
</div>
<!-- 正在输入 -->
<div v-if="sending" class="msg-row assistant">
<div class="avatar assistant">
<el-icon><Cpu /></el-icon>
</div>
<div class="bubble-wrap">
<div class="meta"><span class="who">AI 助手</span></div>
<div class="bubble assistant typing">
<span class="dot" /><span class="dot" /><span class="dot" />
<span class="typing-text">正在思考…</span>
</div>
</div>
</div>
</template>
</div>
<!-- 输入区 -->
<div class="composer">
<div class="composer-box">
<el-input
v-model="question"
type="textarea"
:rows="2"
resize="none"
maxlength="2000"
show-word-limit
:disabled="sending"
placeholder="输入问题,Enter 发送,Shift+Enter 换行"
@keydown="onKeydown"
/>
<div class="composer-footer">
<div class="hints">
<el-tag size="small" effect="plain" type="info">{{ mode === 'RAG' ? 'RAG 模式' : '纯 AI 模式' }}</el-tag>
<span class="tip">回答仅供参考,不能替代执业医师诊疗意见</span>
</div>
<el-button
type="primary"
:loading="sending"
:disabled="!question.trim() || sending"
@click="onSendClick"
>
<el-icon v-if="!sending"><Promotion /></el-icon>
{{ sending ? '发送中' : '发送' }}
</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, nextTick, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ChatDotRound,
ChatLineRound,
Cpu,
Collection,
Refresh,
Delete,
Document,
ArrowUp,
ArrowDown,
CopyDocument,
Promotion
} from '@element-plus/icons-vue'
import { aiChat, aiChatHistory, clearAiChatHistory } from '@/api/ai'
import { useUserStore } from '@/stores/user'
import { renderMarkdown } from '@/utils/markdown'
const userStore = useUserStore()
const mode = ref('RAG')
const modeOptions = [
{ label: '纯 AI 问答', value: 'CHAT' },
{ label: 'RAG 知识库', value: 'RAG' }
]
const question = ref('')
const sending = ref(false)
const historyLoading = ref(false)
const messages = ref([])
const listRef = ref()
let msgSeq = 1
const presets = [
'高血压一线用药有哪些?',
'社区获得性肺炎如何初步处理?',
'胸部 CT 检查前要注意什么?',
'如何预约挂号?'
]
const userInitial = computed(() => (userStore.displayName || '我').slice(0, 1))
const pairCount = computed(() => Math.floor(messages.value.filter((m) => m.role === 'user').length))
const nowTime = () => {
const d = new Date()
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
const formatHistTime = (v) => {
if (!v) return ''
const s = String(v).replace('T', ' ')
return s.length >= 16 ? s.slice(11, 16) : s
}
const scrollBottom = async (smooth = true) => {
await nextTick()
const el = listRef.value
if (!el) return
el.scrollTo({ top: el.scrollHeight, behavior: smooth ? 'smooth' : 'auto' })
}
const loadHistory = async () => {
historyLoading.value = true
try {
const list = await aiChatHistory({ limit: 40 })
const rows = Array.isArray(list) ? list.slice().reverse() : []
const mapped = []
for (const h of rows) {
mapped.push({
id: `u-${h.id}`,
role: 'user',
content: h.question,
mode: h.mode,
time: formatHistTime(h.createdAt)
})
let sources = []
try {
sources = h.sourcesJson ? JSON.parse(h.sourcesJson) : []
} catch {
sources = []
}
mapped.push({
id: `a-${h.id}`,
role: 'assistant',
content: h.answer,
mode: h.mode,
sources,
sourcesOpen: false,
time: formatHistTime(h.createdAt)
})
}
messages.value = mapped
await scrollBottom(false)
} finally {
historyLoading.value = false
}
}
/** 仅接受字符串作为覆盖文本;避免 @click 把 MouseEvent 传进来 */
const send = async (text) => {
const raw = typeof text === 'string' ? text : question.value
const q = String(raw || '').trim()
if (!q || sending.value) return
const fromInput = typeof text !== 'string'
const userMsg = {
id: `local-u-${msgSeq++}`,
role: 'user',
content: q,
mode: mode.value,
time: nowTime()
}
messages.value.push(userMsg)
if (fromInput) question.value = ''
sending.value = true
await scrollBottom()
try {
const res = await aiChat({ mode: mode.value, question: q })
messages.value.push({
id: `local-a-${msgSeq++}`,
role: 'assistant',
content: res?.answer || '(无回复)',
mode: res?.mode || mode.value,
sources: res?.sources || [],
sourcesOpen: true,
usedLlm: !!res?.usedLlm,
fallback: !!res?.fallback,
time: nowTime()
})
} catch {
messages.value.push({
id: `local-e-${msgSeq++}`,
role: 'assistant',
content: '请求失败,请稍后重试。若持续失败,请管理员检查「AI 配置」中的接口与密钥。',
mode: mode.value,
fallback: true,
error: true,
retryQuestion: q,
time: nowTime()
})
} finally {
sending.value = false
await scrollBottom()
}
}
const onSendClick = () => {
send()
}
const askPreset = (text) => {
mode.value = 'RAG'
send(text)
}
const retry = (m) => {
if (m.retryQuestion) send(m.retryQuestion)
}
const onKeydown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}
const copyText = async (text) => {
try {
await navigator.clipboard.writeText(text || '')
ElMessage.success('已复制到剪贴板')
} catch {
ElMessage.warning('复制失败,请手动选择文本')
}
}
const onClear = async () => {
await ElMessageBox.confirm('确定清空当前账号的全部对话历史?此操作不可恢复。', '清空历史', {
type: 'warning',
confirmButtonText: '清空',
cancelButtonText: '取消'
})
await clearAiChatHistory()
messages.value = []
ElMessage.success('历史已清空')
}
onMounted(loadHistory)
</script>
<style scoped>
.assistant-page {
height: calc(100vh - 108px);
min-height: 520px;
display: flex;
}
.shell {
flex: 1;
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.96);
border-radius: 16px;
border: 1px solid #e8eef5;
box-shadow: 0 8px 28px rgba(15, 61, 102, 0.06);
overflow: hidden;
min-height: 0;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 14px 18px;
border-bottom: 1px solid #eef2f7;
background: linear-gradient(180deg, #f8fbff 0%, #fff 100%);
flex-shrink: 0;
}
.topbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: linear-gradient(135deg, #1e6091, #38bdf8);
}
.title {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.sub {
margin-top: 2px;
font-size: 12px;
color: #94a3b8;
}
.topbar-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.mode-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 18px;
font-size: 13px;
color: #475569;
border-bottom: 1px solid #eef2f7;
flex-shrink: 0;
}
.mode-bar.rag {
background: #f0fdf4;
color: #166534;
}
.mode-bar.chat {
background: #eff6ff;
color: #1d4ed8;
}
.mode-bar .msg-count {
margin-left: auto;
font-size: 12px;
opacity: 0.85;
}
.chat-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 18px 16px 12px;
background:
radial-gradient(circle at 10% 0%, rgba(56, 189, 248, 0.06), transparent 40%),
radial-gradient(circle at 90% 20%, rgba(30, 96, 145, 0.05), transparent 35%),
#f5f8fc;
scrollbar-width: thin;
}
.welcome {
max-width: 640px;
margin: 40px auto;
text-align: center;
padding: 24px 16px;
}
.welcome-icon {
width: 72px;
height: 72px;
margin: 0 auto 14px;
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
color: #1e6091;
background: #e0f2fe;
}
.welcome h3 {
margin: 0 0 8px;
font-size: 20px;
color: #0f172a;
}
.welcome p {
margin: 0 0 18px;
color: #64748b;
font-size: 13px;
line-height: 1.6;
}
.presets {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
.preset {
border: 1px solid #dbe3ee;
background: #fff;
color: #334155;
border-radius: 999px;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
transition: all 0.15s ease;
}
.preset:hover {
border-color: #93c5fd;
background: #eff6ff;
color: #1d4ed8;
}
.msg-row {
display: flex;
gap: 10px;
margin-bottom: 16px;
max-width: 900px;
margin-left: auto;
margin-right: auto;
}
.msg-row.user {
flex-direction: row-reverse;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 10px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
}
.avatar.assistant {
background: linear-gradient(135deg, #1e6091, #38bdf8);
color: #fff;
}
.avatar.user {
background: #e2e8f0;
color: #334155;
}
.bubble-wrap {
max-width: min(720px, calc(100% - 50px));
min-width: 0;
}
.msg-row.user .bubble-wrap {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-bottom: 6px;
font-size: 12px;
color: #94a3b8;
}
.msg-row.user .meta {
justify-content: flex-end;
}
.who {
font-weight: 600;
color: #64748b;
}
.time {
margin-left: 2px;
}
.bubble {
padding: 12px 14px;
border-radius: 14px;
line-height: 1.65;
word-break: break-word;
}
.bubble.user {
background: linear-gradient(135deg, #dbeafe, #e0f2fe);
color: #0f172a;
border-bottom-right-radius: 4px;
}
.bubble.assistant {
background: #fff;
border: 1px solid #e2e8f0;
box-shadow: 0 2px 10px rgba(15, 61, 102, 0.04);
border-bottom-left-radius: 4px;
}
.content {
font-size: 14px;
color: #0f172a;
white-space: pre-wrap;
}
/* Markdown 渲染样式(助手消息) */
.md-body {
white-space: normal;
line-height: 1.7;
word-break: break-word;
}
.md-body :deep(p) {
margin: 0 0 0.65em;
}
.md-body :deep(p:last-child) {
margin-bottom: 0;
}
.md-body :deep(h1),
.md-body :deep(h2),
.md-body :deep(h3),
.md-body :deep(h4) {
margin: 0.9em 0 0.45em;
font-weight: 700;
color: #0f172a;
line-height: 1.35;
}
.md-body :deep(h1) { font-size: 1.25em; }
.md-body :deep(h2) { font-size: 1.12em; }
.md-body :deep(h3) { font-size: 1.05em; }
.md-body :deep(ul),
.md-body :deep(ol) {
margin: 0.4em 0 0.7em;
padding-left: 1.4em;
}
.md-body :deep(li) {
margin: 0.2em 0;
}
.md-body :deep(li > p) {
margin: 0.15em 0;
}
.md-body :deep(blockquote) {
margin: 0.5em 0;
padding: 0.35em 0.85em;
border-left: 3px solid #93c5fd;
background: #f0f9ff;
color: #334155;
border-radius: 0 6px 6px 0;
}
.md-body :deep(code) {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.9em;
padding: 0.12em 0.4em;
border-radius: 4px;
background: #f1f5f9;
color: #be185d;
}
.md-body :deep(pre) {
margin: 0.55em 0;
padding: 10px 12px;
border-radius: 8px;
background: #0f172a;
overflow-x: auto;
}
.md-body :deep(pre code) {
padding: 0;
background: transparent;
color: #e2e8f0;
font-size: 0.88em;
line-height: 1.55;
}
.md-body :deep(a) {
color: #2563eb;
text-decoration: none;
}
.md-body :deep(a:hover) {
text-decoration: underline;
}
.md-body :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 0.6em 0;
font-size: 0.92em;
}
.md-body :deep(th),
.md-body :deep(td) {
border: 1px solid #e2e8f0;
padding: 6px 10px;
text-align: left;
}
.md-body :deep(th) {
background: #f8fafc;
font-weight: 600;
}
.md-body :deep(hr) {
border: none;
border-top: 1px solid #e2e8f0;
margin: 0.9em 0;
}
.md-body :deep(strong) {
font-weight: 700;
color: #0f172a;
}
.sources {
margin-top: 12px;
border-top: 1px dashed #e2e8f0;
padding-top: 10px;
}
.sources-head {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: #475569;
cursor: pointer;
user-select: none;
}
.sources-head .chev {
margin-left: auto;
}
.sources-body {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.source-card {
background: #f8fafc;
border: 1px solid #eef2f7;
border-radius: 10px;
padding: 10px 12px;
}
.source-title {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 13px;
color: #0f172a;
}
.source-title .idx {
width: 18px;
height: 18px;
border-radius: 50%;
background: #dbeafe;
color: #1d4ed8;
font-size: 11px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
}
.source-card p {
margin: 6px 0 0;
font-size: 12px;
color: #64748b;
line-height: 1.55;
}
.bubble-actions {
margin-top: 8px;
display: flex;
gap: 4px;
}
.bubble.typing {
display: flex;
align-items: center;
gap: 6px;
min-width: 88px;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #94a3b8;
animation: bounce 1.2s infinite ease-in-out;
}
.dot:nth-child(2) {
animation-delay: 0.15s;
}
.dot:nth-child(3) {
animation-delay: 0.3s;
}
.typing-text {
margin-left: 4px;
font-size: 12px;
color: #94a3b8;
}
@keyframes bounce {
0%,
80%,
100% {
transform: translateY(0);
opacity: 0.5;
}
40% {
transform: translateY(-4px);
opacity: 1;
}
}
.composer {
flex-shrink: 0;
padding: 12px 16px 16px;
border-top: 1px solid #eef2f7;
background: #fff;
}
.composer-box {
max-width: 900px;
margin: 0 auto;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 10px 12px 8px;
background: #fafcff;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.composer-box:focus-within {
border-color: #93c5fd;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
}
.composer-box :deep(.el-textarea__inner) {
box-shadow: none !important;
background: transparent;
padding: 4px 4px 0;
}
.composer-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
flex-wrap: wrap;
}
.hints {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.tip {
font-size: 12px;
color: #94a3b8;
}
@media (max-width: 768px) {
.assistant-page {
height: auto;
min-height: calc(100vh - 120px);
}
.chat-list {
min-height: 360px;
}
.sub {
display: none;
}
}
</style>
+322
View File
@@ -0,0 +1,322 @@
<template>
<div class="page">
<el-card shadow="never" class="panel">
<template #header>
<div class="header">
<div>
<div class="title">{{ isAdmin ? '知识库管理' : '临床知识库' }}</div>
<div class="sub">
供 AI 助手 RAG 与病历辅助决策检索的本地医疗知识
<span v-if="!isAdmin">(浏览模式,增删改请使用管理员账号)</span>
</div>
</div>
<div class="tools">
<el-input
v-model="keyword"
placeholder="搜索标题 / 分类 / 内容"
clearable
style="width: 240px"
@clear="reload"
@keyup.enter="reload"
/>
<el-button @click="reload">查询</el-button>
<el-button v-if="isAdmin" type="primary" @click="openForm()">新增文档</el-button>
</div>
</div>
</template>
<el-table :data="rows" v-loading="loading" stripe empty-text="暂无知识文档">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="title" label="标题" min-width="180" show-overflow-tooltip />
<el-table-column prop="category" label="分类" width="120" />
<el-table-column label="状态" :width="isAdmin ? 130 : 90" align="center">
<template #default="{ row }">
<div v-if="isAdmin" class="status-switch">
<el-switch
:model-value="row.enabled !== false"
:loading="togglingId === row.id"
inline-prompt
active-text="启用"
inactive-text="停用"
style="--el-switch-on-color: #67c23a; --el-switch-off-color: #909399"
@change="(val) => toggleEnabled(row, val)"
/>
</div>
<el-tag v-else :type="row.enabled === false ? 'info' : 'success'" size="small" effect="light">
{{ row.enabled === false ? '停用' : '启用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="content" label="摘要" min-width="220" show-overflow-tooltip />
<el-table-column label="操作" :width="isAdmin ? 180 : 100" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="viewDoc(row)">查看</el-button>
<template v-if="isAdmin">
<el-button link type="primary" @click="openForm(row)">编辑</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pager.page"
v-model:page-size="pager.size"
:total="pager.total"
layout="total, prev, pager, next, sizes"
:page-sizes="[10, 20, 50]"
class="pager"
@current-change="loadPage"
@size-change="reload"
/>
</el-card>
<el-dialog
v-model="dialogVisible"
:title="form.id ? '编辑文档' : '新增文档'"
width="640px"
destroy-on-close
align-center
append-to-body
class="form-dialog"
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" maxlength="200" />
</el-form-item>
<el-row :gutter="12">
<el-col :span="14">
<el-form-item label="分类">
<el-select v-model="form.category" filterable allow-create default-first-option style="width: 100%">
<el-option label="诊疗指南" value="诊疗指南" />
<el-option label="用药说明" value="用药说明" />
<el-option label="检查须知" value="检查须知" />
<el-option label="医院制度" value="医院制度" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="10">
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="正文" prop="content">
<el-input v-model="form.content" type="textarea" :rows="10" placeholder="写入可供检索的知识正文" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" :loading="saving" @click="save">保 存</el-button>
</template>
</el-dialog>
<el-dialog v-model="viewVisible" title="知识详情" width="640px" destroy-on-close align-center append-to-body class="form-dialog">
<template v-if="viewDocData">
<div class="view-meta">
<el-tag size="small">{{ viewDocData.category || '未分类' }}</el-tag>
<el-tag size="small" :type="viewDocData.enabled === false ? 'info' : 'success'">
{{ viewDocData.enabled === false ? '停用' : '启用' }}
</el-tag>
</div>
<h3 class="view-title">{{ viewDocData.title }}</h3>
<pre class="view-body">{{ viewDocData.content }}</pre>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
listKnowledge,
createKnowledge,
updateKnowledge,
deleteKnowledge,
setKnowledgeEnabled
} from '@/api/ai'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const isAdmin = computed(() => userStore.isAdmin)
const rows = ref([])
const loading = ref(false)
const togglingId = ref(null)
const keyword = ref('')
const pager = reactive({ page: 1, size: 10, total: 0 })
const dialogVisible = ref(false)
const viewVisible = ref(false)
const viewDocData = ref(null)
const saving = ref(false)
const formRef = ref()
const emptyForm = () => ({
id: null,
title: '',
category: '诊疗指南',
content: '',
enabled: true
})
const form = reactive(emptyForm())
const rules = {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入正文', trigger: 'blur' }]
}
const viewDoc = (row) => {
viewDocData.value = row
viewVisible.value = true
}
const loadPage = async () => {
loading.value = true
try {
const data = await listKnowledge({
page: pager.page - 1,
size: pager.size,
keyword: keyword.value || undefined
})
rows.value = data?.content || []
pager.total = data?.totalElements || 0
} catch {
rows.value = []
pager.total = 0
} finally {
loading.value = false
}
}
const reload = () => {
pager.page = 1
loadPage()
}
const openForm = (row) => {
if (row) {
Object.assign(form, {
id: row.id,
title: row.title,
category: row.category || '其他',
content: row.content,
enabled: row.enabled !== false
})
} else {
Object.assign(form, emptyForm())
}
dialogVisible.value = true
}
const save = async () => {
await formRef.value.validate()
saving.value = true
try {
const payload = {
title: form.title,
category: form.category,
content: form.content,
enabled: form.enabled
}
if (form.id) await updateKnowledge(form.id, payload)
else await createKnowledge(payload)
ElMessage.success('保存成功')
dialogVisible.value = false
loadPage()
} finally {
saving.value = false
}
}
const remove = async (row) => {
await ElMessageBox.confirm(`删除知识文档「${row.title}」?`, '确认', { type: 'warning' })
await deleteKnowledge(row.id)
ElMessage.success('已删除')
loadPage()
}
/** 列表一键启用 / 停用 */
const toggleEnabled = async (row, enabled) => {
const next = !!enabled
const prev = row.enabled !== false
if (next === prev) return
togglingId.value = row.id
row.enabled = next
try {
const data = await setKnowledgeEnabled(row.id, next)
row.enabled = data?.enabled !== false
ElMessage.success(next ? `已启用「${row.title}」` : `已停用「${row.title}」(不再参与 RAG 检索)`)
} catch {
row.enabled = prev
} finally {
togglingId.value = null
}
}
onMounted(loadPage)
</script>
<style scoped>
.status-switch {
display: flex;
justify-content: center;
align-items: center;
}
.panel {
border: none;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.title {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.sub {
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
}
.tools {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.pager {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.view-meta {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.view-title {
margin: 0 0 12px;
color: #0f172a;
}
.view-body {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
line-height: 1.7;
color: #334155;
background: #f8fafc;
border-radius: 10px;
padding: 12px 14px;
max-height: 420px;
overflow: auto;
}
</style>
+173
View File
@@ -0,0 +1,173 @@
<template>
<div class="page">
<el-card shadow="never" class="panel" v-loading="loading">
<template #header>
<div class="header">
<div>
<div class="title">AI 配置</div>
<div class="sub">配置兼容 OpenAI 协议的大模型接口(Base URL / API Key / 模型)</div>
</div>
<div class="tools">
<el-button :loading="testing" @click="onTest">测试连接</el-button>
<el-button type="primary" :loading="saving" @click="onSave">保存配置</el-button>
</div>
</div>
</template>
<el-alert
type="info"
:closable="false"
show-icon
class="mb16"
title="支持 OpenAI / DeepSeek / 通义兼容模式 / Ollama 等提供 Chat Completions 的服务。"
description="请求路径为 {Base URL}/v1/chat/completions。保存后会同步到 AI 微服务:对话、影像报告、病历决策共用此配置。若仅改 .env 未在此启用,影像诊断仍可能走模板。"
/>
<el-form ref="formRef" :model="form" label-width="120px" style="max-width: 720px">
<el-form-item label="启用大模型">
<el-switch v-model="form.enabled" active-text="启用" inactive-text="关闭" />
</el-form-item>
<el-form-item label="API Base URL">
<el-input v-model="form.apiBaseUrl" placeholder="https://api.openai.com" />
</el-form-item>
<el-form-item label="API Key">
<el-input
v-model="form.apiKey"
type="password"
show-password
:placeholder="form.apiKeyConfigured ? '已配置(留空则不修改)' : '请输入 API Key'"
/>
<div v-if="form.apiKeyConfigured" class="hint">当前已配置密钥(显示为脱敏值),修改时请重新完整填写</div>
</el-form-item>
<el-form-item label="模型名称">
<el-input v-model="form.model" placeholder="gpt-4o-mini / deepseek-chat" />
</el-form-item>
<el-form-item label="温度">
<el-slider
v-model="form.temperature"
:min="0"
:max="2"
:step="0.1"
show-input
:show-input-controls="false"
style="width: 100%; max-width: 100%; box-sizing: border-box"
/>
</el-form-item>
<el-form-item label="系统提示词">
<el-input v-model="form.systemPrompt" type="textarea" :rows="5" placeholder="定义助手角色与回答规范" />
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getAiSettings, saveAiSettings, testAiSettings } from '@/api/ai'
const loading = ref(false)
const saving = ref(false)
const testing = ref(false)
const formRef = ref()
const form = reactive({
apiBaseUrl: 'https://api.openai.com',
apiKey: '',
apiKeyConfigured: false,
model: 'gpt-4o-mini',
enabled: false,
temperature: 0.7,
systemPrompt: ''
})
const load = async () => {
loading.value = true
try {
const data = await getAiSettings()
Object.assign(form, {
apiBaseUrl: data?.apiBaseUrl || '',
apiKey: '',
apiKeyConfigured: !!data?.apiKeyConfigured,
model: data?.model || '',
enabled: !!data?.enabled,
temperature: data?.temperature ?? 0.7,
systemPrompt: data?.systemPrompt || ''
})
} finally {
loading.value = false
}
}
const onSave = async () => {
saving.value = true
try {
const payload = {
apiBaseUrl: form.apiBaseUrl,
model: form.model,
enabled: form.enabled,
temperature: form.temperature,
systemPrompt: form.systemPrompt
}
if (form.apiKey && form.apiKey.trim()) {
payload.apiKey = form.apiKey.trim()
}
const data = await saveAiSettings(payload)
form.apiKey = ''
form.apiKeyConfigured = !!data?.apiKeyConfigured
ElMessage.success('配置已保存')
} finally {
saving.value = false
}
}
const onTest = async () => {
testing.value = true
try {
const data = await testAiSettings()
ElMessage.success(`连接成功:${data?.reply || 'OK'}`)
} catch {
/* 拦截器已提示 */
} finally {
testing.value = false
}
}
onMounted(load)
</script>
<style scoped>
.panel {
border: none;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.title {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.sub {
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
}
.tools {
display: flex;
gap: 8px;
}
.mb16 {
margin-bottom: 16px;
}
.hint {
margin-top: 6px;
font-size: 12px;
color: #94a3b8;
}
</style>
File diff suppressed because it is too large Load Diff
+853
View File
@@ -0,0 +1,853 @@
<template>
<div class="page">
<!-- 统计概览 -->
<el-row :gutter="12" class="stat-row">
<el-col :xs="12" :sm="8" :md="4" v-for="item in statCards" :key="item.key">
<div
class="stat-card"
:class="[{ active: item.active }, item.key]"
@click="applyQuick(item)"
>
<div class="stat-label">{{ item.label }}</div>
<div class="stat-value">{{ item.value }}</div>
</div>
</el-col>
</el-row>
<el-card shadow="never" class="panel">
<template #header>
<div class="header">
<div>
<div class="title">预约挂号</div>
<div class="sub">安排就诊时段,跟踪预约进度</div>
</div>
<el-button type="primary" @click="openForm()">新建预约</el-button>
</div>
</template>
<!-- 快捷筛选 -->
<div class="filter-bar">
<el-radio-group v-model="quickDate" size="default" @change="onQuickDate">
<el-radio-button value="all">全部日期</el-radio-button>
<el-radio-button value="today">今日</el-radio-button>
<el-radio-button value="tomorrow">明日</el-radio-button>
</el-radio-group>
<el-radio-group v-model="filters.status" size="default" @change="reload">
<el-radio-button value="">全部状态</el-radio-button>
<el-radio-button value="SCHEDULED">已预约</el-radio-button>
<el-radio-button value="CONFIRMED">已确认</el-radio-button>
<el-radio-button value="COMPLETED">已完成</el-radio-button>
<el-radio-button value="CANCELLED">已取消</el-radio-button>
<el-radio-button value="NO_SHOW">未到诊</el-radio-button>
</el-radio-group>
<div class="filter-right">
<el-input
v-model="filters.keyword"
placeholder="患者 / 科室 / 事由"
clearable
style="width: 200px"
@clear="reload"
@keyup.enter="reload"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-date-picker
v-model="filters.date"
type="date"
value-format="YYYY-MM-DD"
placeholder="指定日期"
style="width: 150px"
clearable
@change="onDatePick"
/>
<el-button @click="reload">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</div>
<el-table
:data="rows"
v-loading="loading"
stripe
empty-text="暂无预约数据"
row-class-name="appt-row"
@row-click="onRowClick"
>
<el-table-column prop="id" label="编号" width="70" />
<el-table-column label="患者" min-width="130">
<template #default="{ row }">
<div class="patient-cell">
<span class="p-name">{{ row.patientName || '—' }}</span>
<span v-if="row.patientPhone" class="p-phone">{{ row.patientPhone }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="doctorName" label="接诊医生" min-width="100" />
<el-table-column prop="department" label="科室" min-width="90">
<template #default="{ row }">
<el-tag size="small" effect="plain" type="info">{{ row.department || '—' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="预约时间" min-width="170">
<template #default="{ row }">
<div class="datetime-cell">
<span :class="{ 'is-today': isToday(row.appointmentDate), 'is-past': isPast(row) }">
{{ row.appointmentDate || '—' }}
</span>
<span v-if="row.appointmentTime" class="time">{{ formatTime(row.appointmentTime) }}</span>
<el-tag v-if="isToday(row.appointmentDate)" size="small" type="warning" effect="plain" class="today-tag">
今日
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="reason" label="事由" min-width="120" show-overflow-tooltip />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="appointmentStatusType(row.status)" effect="light" size="small">
{{ appointmentStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right" align="left">
<template #default="{ row }">
<div class="ops" @click.stop>
<el-button link type="primary" class="op-btn" @click="openDetail(row)">详情</el-button>
<el-button
v-if="canConfirm(row)"
link
type="success"
class="op-btn"
@click="changeStatus(row, 'CONFIRMED')"
>
确认
</el-button>
<el-button
v-if="canComplete(row)"
link
type="success"
class="op-btn"
@click="changeStatus(row, 'COMPLETED')"
>
完成
</el-button>
<el-button link type="primary" class="op-btn" @click="openForm(row)">编辑</el-button>
<el-dropdown trigger="click" class="op-drop" @command="(cmd) => changeStatus(row, cmd)">
<el-button link type="warning" class="op-btn">更多</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-if="row.status !== 'SCHEDULED'"
command="SCHEDULED"
>
恢复预约
</el-dropdown-item>
<el-dropdown-item
v-if="row.status !== 'NO_SHOW'"
command="NO_SHOW"
>
标记未到诊
</el-dropdown-item>
<el-dropdown-item
v-if="row.status !== 'CANCELLED'"
command="CANCELLED"
divided
>
取消预约
</el-dropdown-item>
<el-dropdown-item command="__delete__" divided>
<span class="danger-text">删除记录</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pager.page"
v-model:page-size="pager.size"
:total="pager.total"
layout="total, prev, pager, next, sizes"
:page-sizes="[10, 20, 50]"
class="pager"
@current-change="loadPage"
@size-change="reload"
/>
</el-card>
<!-- 新建 / 编辑 -->
<el-dialog
v-model="dialogVisible"
:title="form.id ? '编辑预约' : '新建预约'"
width="600px"
destroy-on-close
align-center
append-to-body
class="form-dialog"
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="患者" prop="patientId">
<el-select
v-model="form.patientId"
filterable
placeholder="搜索并选择患者"
style="width: 100%"
>
<el-option
v-for="p in patients"
:key="p.id"
:label="`${p.name}${p.phone ? ' · ' + p.phone : ''}(#${p.id})`"
:value="p.id"
/>
</el-select>
</el-form-item>
<el-form-item label="接诊医生" prop="doctorId">
<el-select
v-model="form.doctorId"
filterable
clearable
placeholder="默认当前登录账号"
style="width: 100%"
@change="onDoctorChange"
>
<el-option
v-for="d in doctors"
:key="d.id"
:label="`${d.realName || d.username}${d.department ? ' · ' + d.department : ''}`"
:value="d.id"
/>
</el-select>
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="科室" prop="department">
<el-select
v-model="form.department"
filterable
allow-create
default-first-option
placeholder="选择或输入科室"
style="width: 100%"
>
<el-option v-for="d in departmentOptions" :key="d" :label="d" :value="d" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-select v-model="form.status" style="width: 100%">
<el-option label="已预约" value="SCHEDULED" />
<el-option label="已确认" value="CONFIRMED" />
<el-option label="已完成" value="COMPLETED" />
<el-option label="已取消" value="CANCELLED" />
<el-option label="未到诊" value="NO_SHOW" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="预约日期" prop="appointmentDate">
<el-date-picker
v-model="form.appointmentDate"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="选择日期"
:disabled-date="disablePastDate"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="时段" prop="appointmentTime">
<el-time-select
v-model="form.appointmentTime"
start="08:00"
step="00:30"
end="18:00"
placeholder="选择时间"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="事由">
<el-select
v-model="form.reason"
filterable
allow-create
default-first-option
clearable
placeholder="选择或输入事由"
style="width: 100%"
>
<el-option v-for="r in reasonOptions" :key="r" :label="r" :value="r" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.notes" type="textarea" :rows="2" placeholder="可选,如特殊注意事项" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" :loading="saving" @click="save">保 存</el-button>
</template>
</el-dialog>
<!-- 详情抽屉 -->
<el-drawer v-model="detailVisible" title="预约详情" size="420px" destroy-on-close append-to-body>
<template v-if="detail">
<div class="detail-status">
<el-tag :type="appointmentStatusType(detail.status)" size="large" effect="dark">
{{ appointmentStatusLabel(detail.status) }}
</el-tag>
<el-tag v-if="isToday(detail.appointmentDate)" type="warning" effect="plain">今日就诊</el-tag>
</div>
<el-descriptions :column="1" border size="default" class="detail-desc">
<el-descriptions-item label="预约编号">#{{ detail.id }}</el-descriptions-item>
<el-descriptions-item label="患者">{{ detail.patientName || '—' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ detail.patientPhone || '—' }}</el-descriptions-item>
<el-descriptions-item label="接诊医生">{{ detail.doctorName || '—' }}</el-descriptions-item>
<el-descriptions-item label="科室">{{ detail.department || '—' }}</el-descriptions-item>
<el-descriptions-item label="预约时间">
{{ detail.appointmentDate || '—' }}
{{ detail.appointmentTime ? formatTime(detail.appointmentTime) : '' }}
</el-descriptions-item>
<el-descriptions-item label="事由">{{ detail.reason || '—' }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ detail.notes || '—' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(detail.createdAt) }}</el-descriptions-item>
</el-descriptions>
<div class="detail-actions">
<el-button v-if="canConfirm(detail)" type="success" @click="changeStatus(detail, 'CONFIRMED')">
确认预约
</el-button>
<el-button v-if="canComplete(detail)" type="primary" @click="changeStatus(detail, 'COMPLETED')">
完成就诊
</el-button>
<el-button @click="openForm(detail); detailVisible = false">编辑</el-button>
<el-button
v-if="detail.status !== 'CANCELLED'"
type="danger"
plain
@click="changeStatus(detail, 'CANCELLED')"
>
取消预约
</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import {
listAppointments,
createAppointment,
updateAppointment,
updateAppointmentStatus,
deleteAppointment,
getAppointmentStats,
listAppointmentDoctors
} from '@/api/appointment'
import { listPatients } from '@/api/patient'
import { useUserStore } from '@/stores/user'
import {
appointmentStatusLabel,
appointmentStatusType,
formatTime,
formatDateTime
} from '@/utils/labels'
const userStore = useUserStore()
const rows = ref([])
const loading = ref(false)
const patients = ref([])
const doctors = ref([])
const pager = reactive({ page: 1, size: 10, total: 0 })
const filters = reactive({ keyword: '', status: '', date: '' })
const quickDate = ref('all')
const stats = ref({
total: 0,
today: 0,
tomorrow: 0,
scheduled: 0,
confirmed: 0,
completed: 0,
cancelled: 0,
noShow: 0,
todayPending: 0
})
const departmentOptions = ['内科', '外科', '儿科', '妇科', '骨科', '影像科', '心内科', '神经内科', '皮肤科', '急诊科']
const reasonOptions = ['初诊', '复诊', '体检', '取报告', '换药', '随访', '会诊']
const todayStr = () => new Date().toISOString().slice(0, 10)
const tomorrowStr = () => {
const d = new Date()
d.setDate(d.getDate() + 1)
return d.toISOString().slice(0, 10)
}
const isToday = (date) => date && String(date).slice(0, 10) === todayStr()
const isPast = (row) => {
if (!row.appointmentDate) return false
if (['COMPLETED', 'CANCELLED', 'NO_SHOW'].includes(row.status)) return false
const d = String(row.appointmentDate).slice(0, 10)
return d < todayStr()
}
const canConfirm = (row) => row.status === 'SCHEDULED'
const canComplete = (row) => row.status === 'SCHEDULED' || row.status === 'CONFIRMED'
const statCards = computed(() => [
{
key: 'today',
label: '今日预约',
value: stats.value.today ?? 0,
active: quickDate.value === 'today' && !filters.status,
date: 'today'
},
{
key: 'pending',
label: '今日待处理',
value: stats.value.todayPending ?? 0,
active: quickDate.value === 'today' && !filters.status,
date: 'today',
status: ''
},
{
key: 'scheduled',
label: '已预约',
value: stats.value.scheduled ?? 0,
active: filters.status === 'SCHEDULED',
status: 'SCHEDULED'
},
{
key: 'confirmed',
label: '已确认',
value: stats.value.confirmed ?? 0,
active: filters.status === 'CONFIRMED',
status: 'CONFIRMED'
},
{
key: 'completed',
label: '已完成',
value: stats.value.completed ?? 0,
active: filters.status === 'COMPLETED',
status: 'COMPLETED'
},
{
key: 'total',
label: '全部预约',
value: stats.value.total ?? 0,
active: quickDate.value === 'all' && !filters.status && !filters.date,
clear: true
}
])
const dialogVisible = ref(false)
const saving = ref(false)
const formRef = ref()
const emptyForm = () => ({
id: null,
patientId: null,
doctorId: null,
department: '内科',
appointmentDate: '',
appointmentTime: '09:00',
reason: '',
notes: '',
status: 'SCHEDULED'
})
const form = reactive(emptyForm())
const rules = {
patientId: [{ required: true, message: '请选择患者', trigger: 'change' }],
department: [{ required: true, message: '请填写科室', trigger: 'change' }],
appointmentDate: [{ required: true, message: '请选择日期', trigger: 'change' }]
}
const detailVisible = ref(false)
const detail = ref(null)
const disablePastDate = (time) => {
const start = new Date()
start.setHours(0, 0, 0, 0)
// 编辑已有记录时允许保留过去日期,新建时禁用过去
if (form.id) return false
return time.getTime() < start.getTime()
}
const loadStats = async () => {
try {
stats.value = (await getAppointmentStats()) || stats.value
} catch {
/* ignore */
}
}
const loadPage = async () => {
loading.value = true
try {
const data = await listAppointments({
page: pager.page - 1,
size: pager.size,
keyword: filters.keyword || undefined,
status: filters.status || undefined,
date: filters.date || undefined
})
rows.value = data?.content || []
pager.total = data?.totalElements || 0
} catch {
rows.value = []
pager.total = 0
} finally {
loading.value = false
}
}
const reload = () => {
pager.page = 1
loadPage()
loadStats()
}
const onQuickDate = (val) => {
if (val === 'today') filters.date = todayStr()
else if (val === 'tomorrow') filters.date = tomorrowStr()
else filters.date = ''
reload()
}
const onDatePick = () => {
if (!filters.date) quickDate.value = 'all'
else if (filters.date === todayStr()) quickDate.value = 'today'
else if (filters.date === tomorrowStr()) quickDate.value = 'tomorrow'
else quickDate.value = 'all'
reload()
}
const applyQuick = (item) => {
if (item.clear) {
resetFilters()
return
}
if (item.date === 'today') {
quickDate.value = 'today'
filters.date = todayStr()
}
if (item.status !== undefined) {
filters.status = item.status || ''
}
if (item.key === 'scheduled' || item.key === 'confirmed' || item.key === 'completed') {
// 按状态看全局,不锁日期
quickDate.value = 'all'
filters.date = ''
filters.status = item.status
}
reload()
}
const resetFilters = () => {
filters.keyword = ''
filters.status = ''
filters.date = ''
quickDate.value = 'all'
reload()
}
const loadOptions = async () => {
const [p, d] = await Promise.all([
listPatients({ page: 0, size: 200 }).catch(() => null),
listAppointmentDoctors().catch(() => null)
])
patients.value = p?.content || []
doctors.value = Array.isArray(d) ? d : []
}
const onDoctorChange = (id) => {
const doc = doctors.value.find((x) => x.id === id)
if (doc?.department && !form.department) {
form.department = doc.department
}
}
const openForm = async (row) => {
await loadOptions()
if (row) {
Object.assign(form, {
id: row.id,
patientId: row.patientId,
doctorId: row.doctorId || null,
department: row.department || '内科',
appointmentDate: row.appointmentDate || '',
appointmentTime: row.appointmentTime ? String(row.appointmentTime).slice(0, 5) : '',
reason: row.reason || '',
notes: row.notes || '',
status: row.status || 'SCHEDULED'
})
} else {
Object.assign(form, emptyForm())
form.appointmentDate = todayStr()
// 默认选中当前用户(若在医生列表中)
const me = doctors.value.find((d) => d.username === userStore.userInfo?.username)
if (me) {
form.doctorId = me.id
if (me.department) form.department = me.department
}
}
dialogVisible.value = true
}
const save = async () => {
await formRef.value.validate()
saving.value = true
try {
const payload = {
patientId: form.patientId,
doctorId: form.doctorId || undefined,
department: form.department,
appointmentDate: form.appointmentDate,
appointmentTime: form.appointmentTime || null,
reason: form.reason,
notes: form.notes,
status: form.status
}
if (form.id) await updateAppointment(form.id, payload)
else await createAppointment(payload)
ElMessage.success('保存成功')
dialogVisible.value = false
reload()
} finally {
saving.value = false
}
}
const openDetail = (row) => {
detail.value = { ...row }
detailVisible.value = true
}
const onRowClick = (row, column) => {
if (column?.label === '操作') return
openDetail(row)
}
const changeStatus = async (row, status) => {
if (status === '__delete__') {
await remove(row)
return
}
const label = appointmentStatusLabel(status)
await ElMessageBox.confirm(
`将「${row.patientName}」的预约状态更新为「${label}」?`,
'确认操作',
{ type: 'info' }
)
await updateAppointmentStatus(row.id, status)
ElMessage.success(`已更新为${label}`)
if (detailVisible.value && detail.value?.id === row.id) {
detail.value = { ...detail.value, status }
}
reload()
}
const remove = async (row) => {
await ElMessageBox.confirm(
`确定删除预约 #${row.id}(${row.patientName})?删除后不可恢复。`,
'删除确认',
{ type: 'warning' }
)
await deleteAppointment(row.id)
ElMessage.success('删除成功')
if (detailVisible.value && detail.value?.id === row.id) {
detailVisible.value = false
}
reload()
}
onMounted(() => {
loadPage()
loadStats()
})
</script>
<style scoped>
.stat-row {
margin-bottom: 4px;
}
.stat-card {
background: rgba(255, 255, 255, 0.94);
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 12px;
border: 1px solid #eef2f7;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(15, 61, 102, 0.08);
}
.stat-card.active {
border-color: #409eff;
box-shadow: 0 0 0 1px rgba(64, 158, 255, 0.25);
}
.stat-card.today { border-top: 3px solid #e6a23c; }
.stat-card.pending { border-top: 3px solid #f56c6c; }
.stat-card.scheduled { border-top: 3px solid #909399; }
.stat-card.confirmed { border-top: 3px solid #409eff; }
.stat-card.completed { border-top: 3px solid #67c23a; }
.stat-card.total { border-top: 3px solid #1e6091; }
.stat-label {
font-size: 12px;
color: #64748b;
}
.stat-value {
margin-top: 4px;
font-size: 24px;
font-weight: 700;
color: #0f172a;
line-height: 1.1;
}
.panel {
border: none;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(6px);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.title {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.sub {
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.filter-right {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-left: auto;
}
.patient-cell {
display: flex;
flex-direction: column;
gap: 2px;
line-height: 1.3;
}
.p-name {
font-weight: 600;
color: #0f172a;
}
.p-phone {
font-size: 12px;
color: #94a3b8;
}
.datetime-cell {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.is-today {
color: #d97706;
font-weight: 600;
}
.is-past {
color: #ef4444;
}
.time {
color: #64748b;
}
.today-tag {
margin-left: 2px;
}
/* 操作列统一间距与基线,避免 link 按钮默认 margin 导致疏密不均 */
.ops {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
gap: 4px;
height: 32px;
white-space: nowrap;
vertical-align: middle;
}
.ops :deep(.op-btn.el-button) {
margin: 0 !important;
padding: 0 4px !important;
height: 28px !important;
line-height: 28px !important;
min-height: 28px !important;
font-size: 13px;
vertical-align: middle;
}
.ops :deep(.op-drop),
.ops :deep(.el-dropdown) {
display: inline-flex;
align-items: center;
height: 28px;
line-height: 28px;
vertical-align: middle;
}
.danger-text {
color: #f56c6c;
}
.pager {
margin-top: 16px;
justify-content: flex-end;
display: flex;
}
.detail-status {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 16px;
}
.detail-desc {
margin-bottom: 20px;
}
.detail-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
:deep(.appt-row) {
cursor: pointer;
}
</style>
File diff suppressed because it is too large Load Diff
+490
View File
@@ -0,0 +1,490 @@
<template>
<div class="page">
<el-card shadow="never" class="panel">
<template #header>
<div class="header">
<div>
<div class="title">电子病历</div>
<div class="sub">病历录入与 AI 辅助决策(治疗 / 护理 / 随访 + RAG)</div>
</div>
<div class="tools">
<el-input
v-model="keyword"
placeholder="患者 / 诊断 / 主诉"
clearable
style="width: 220px"
@clear="reload"
@keyup.enter="reload"
>
<template #append>
<el-button @click="reload">搜索</el-button>
</template>
</el-input>
<el-button type="primary" @click="openForm()">新增病历</el-button>
</div>
</div>
</template>
<el-alert
type="success"
:closable="false"
show-icon
class="page-tip"
title="使用提示:新建病历并保存可自动弹出 AI 建议;列表中点「AI 建议」可查看治疗、护理、随访计划及知识库引用。诊断含「肺炎/高血压/糖尿病」等关键词效果更明显。"
/>
<el-table :data="rows" v-loading="loading" stripe empty-text="暂无病历数据">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="patientName" label="患者" min-width="100" />
<el-table-column prop="doctorName" label="主诊医生" min-width="100" />
<el-table-column prop="visitDate" label="就诊日期" width="120" />
<el-table-column prop="diagnosis" label="诊断" min-width="160" show-overflow-tooltip />
<el-table-column prop="treatmentPlan" label="治疗方案" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="viewAi(row)">AI 建议</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pager.page"
v-model:page-size="pager.size"
:total="pager.total"
layout="total, prev, pager, next, sizes"
:page-sizes="[10, 20, 50]"
class="pager"
@current-change="loadPage"
@size-change="reload"
/>
</el-card>
<!-- append-to-body:EP 2.8 默认不 teleport,否则会被主布局 overflow 裁切 -->
<el-dialog
v-model="dialogVisible"
title="新增病历"
width="640px"
destroy-on-close
align-center
append-to-body
class="form-dialog"
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
require-asterisk-position="right"
class="create-form"
>
<div class="form-two-col">
<el-form-item label="患者" prop="patientId">
<el-select v-model="form.patientId" filterable placeholder="请选择患者" style="width: 100%">
<el-option
v-for="p in patients"
:key="p.id"
:label="`${p.name}(ID:${p.id})`"
:value="p.id"
/>
</el-select>
</el-form-item>
<el-form-item label="就诊日期" prop="visitDate">
<el-date-picker
v-model="form.visitDate"
type="date"
value-format="YYYY-MM-DD"
style="width: 100%"
placeholder="选择日期"
/>
</el-form-item>
</div>
<el-form-item label="主诉">
<el-input v-model="form.chiefComplaint" type="textarea" :rows="2" placeholder="患者主诉" />
</el-form-item>
<div class="form-two-col">
<el-form-item label="现病史">
<el-input v-model="form.presentIllness" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="体格检查">
<el-input v-model="form.physicalExamination" type="textarea" :rows="2" />
</el-form-item>
</div>
<el-form-item label="诊断" prop="diagnosis">
<el-input
v-model="form.diagnosis"
type="textarea"
:rows="2"
placeholder="如:高血压、糖尿病、肺炎(关键词会触发 AI 建议)"
/>
</el-form-item>
<div class="form-two-col">
<el-form-item label="治疗方案">
<el-input v-model="form.treatmentPlan" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="用药">
<el-input v-model="form.medications" type="textarea" :rows="2" />
</el-form-item>
</div>
<el-form-item label="随访">
<el-input v-model="form.followUpNotes" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存并获取 AI 建议</el-button>
</template>
</el-dialog>
<el-dialog
v-model="aiDialogVisible"
title="AI 辅助决策建议"
width="760px"
destroy-on-close
align-center
append-to-body
class="form-dialog ai-dialog"
>
<div v-loading="aiLoading">
<template v-if="aiData">
<el-alert
type="info"
:closable="false"
show-icon
:title="aiData.disclaimer || 'AI 结果仅供辅助决策,须由执业医师审核。'"
style="margin-bottom: 12px"
/>
<div class="meta-row" v-if="aiData.engine">
<el-tag size="small">引擎:{{ aiData.engine }}</el-tag>
<el-tag v-if="aiData.fallback" size="small" type="warning">本地降级</el-tag>
</div>
<div class="section">
<div class="section-title">治疗建议</div>
<el-table :data="treatmentRows" size="small" empty-text="暂无治疗建议" border>
<el-table-column prop="category" label="类别" width="140" />
<el-table-column prop="content" label="内容" />
<el-table-column label="置信度" width="100">
<template #default="{ row }">{{ formatPercent(row.confidence) }}</template>
</el-table-column>
</el-table>
</div>
<div class="section">
<div class="section-title">用药建议</div>
<el-table :data="medicationRows" size="small" empty-text="暂无用药建议" border>
<el-table-column prop="name" label="药品" min-width="120" />
<el-table-column prop="dosage" label="剂量" width="140" />
<el-table-column prop="category" label="类别" width="120" />
<el-table-column label="置信度" width="100">
<template #default="{ row }">{{ formatPercent(row.confidence) }}</template>
</el-table-column>
</el-table>
</div>
<div class="section">
<div class="section-title">护理建议</div>
<template v-if="nursingRows.length">
<ul class="bullet-list">
<li v-for="(n, i) in nursingRows" :key="i">{{ n }}</li>
</ul>
</template>
<el-empty v-else description="暂无护理建议" :image-size="64" />
</div>
<div class="section">
<div class="section-title">随访计划</div>
<template v-if="followUpRows.length">
<ul class="bullet-list">
<li v-for="(n, i) in followUpRows" :key="i">{{ n }}</li>
</ul>
</template>
<el-empty v-else description="暂无随访计划" :image-size="64" />
</div>
<div class="section">
<div class="section-title">风险评估</div>
<el-table :data="riskRows" size="small" empty-text="暂无风险评估" border>
<el-table-column prop="type" label="类型" width="140" />
<el-table-column prop="description" label="说明" />
<el-table-column label="等级" width="100">
<template #default="{ row }">
<el-tag size="small" :type="riskLevelType(row.level)">{{ row.level || '—' }}</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<div class="section">
<div class="section-title">药物冲突</div>
<template v-if="conflictRows.length">
<el-alert
v-for="(c, i) in conflictRows"
:key="i"
:title="c"
type="warning"
:closable="false"
show-icon
style="margin-bottom: 8px"
/>
</template>
<el-empty v-else description="未发现明显药物冲突" :image-size="72" />
</div>
<div class="section" v-if="sourceRows.length">
<div class="section-title">知识库引用(RAG)</div>
<el-table :data="sourceRows" size="small" border>
<el-table-column prop="title" label="标题" min-width="140" />
<el-table-column prop="category" label="分类" width="120" />
<el-table-column prop="snippet" label="摘要" min-width="200" show-overflow-tooltip />
</el-table>
</div>
</template>
<el-empty v-else-if="!aiLoading" description="暂无 AI 建议" />
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listEmrs, createEmr, deleteEmr, aiSuggestions } from '@/api/emr'
import { listPatients } from '@/api/patient'
import { formatPercent, riskLevelType } from '@/utils/labels'
const rows = ref([])
const loading = ref(false)
const keyword = ref('')
const pager = reactive({ page: 1, size: 10, total: 0 })
const patients = ref([])
const dialogVisible = ref(false)
const saving = ref(false)
const formRef = ref()
const emptyForm = () => ({
patientId: null,
visitDate: '',
chiefComplaint: '',
presentIllness: '',
physicalExamination: '',
diagnosis: '',
treatmentPlan: '',
medications: '',
followUpNotes: ''
})
const form = reactive(emptyForm())
const rules = {
patientId: [{ required: true, message: '请选择患者', trigger: 'change' }],
visitDate: [{ required: true, message: '请选择就诊日期', trigger: 'change' }],
diagnosis: [{ required: true, message: '请填写诊断', trigger: 'blur' }]
}
const aiDialogVisible = ref(false)
const aiLoading = ref(false)
const aiData = ref(null)
const treatmentRows = computed(() =>
(aiData.value?.suggestions || []).map((s) => ({
category: s.category || s.title || '—',
content: s.content || s.description || '—',
confidence: s.confidence
}))
)
const medicationRows = computed(() =>
(aiData.value?.medicationSuggestions || []).map((m) => ({
name: m.name || m.drugName || '—',
dosage: m.dosage || '—',
category: m.category || m.drugClass || '—',
confidence: m.confidence
}))
)
const riskRows = computed(() =>
(aiData.value?.riskAssessments || []).map((r) => ({
type: r.type || r.riskType || '—',
description: r.description || '—',
level: r.level || '—'
}))
)
const conflictRows = computed(() => aiData.value?.conflicts || [])
const nursingRows = computed(() => aiData.value?.nursingAdvice || aiData.value?.nursing_advice || [])
const followUpRows = computed(() => aiData.value?.followUpPlan || aiData.value?.follow_up_plan || [])
const sourceRows = computed(() =>
(aiData.value?.sources || []).map((s) => ({
title: s.title || '—',
category: s.category || '—',
snippet: s.snippet || '—'
}))
)
const loadPage = async () => {
loading.value = true
try {
const data = await listEmrs({
page: pager.page - 1,
size: pager.size,
keyword: keyword.value || undefined
})
rows.value = data?.content || []
pager.total = data?.totalElements || 0
} catch {
rows.value = []
pager.total = 0
} finally {
loading.value = false
}
}
const reload = () => {
pager.page = 1
loadPage()
}
const openForm = async () => {
Object.assign(form, emptyForm())
form.visitDate = new Date().toISOString().slice(0, 10)
try {
const p = await listPatients({ page: 0, size: 200 })
patients.value = p?.content || []
} catch {
patients.value = []
}
dialogVisible.value = true
}
const save = async () => {
await formRef.value.validate()
saving.value = true
try {
const res = await createEmr({ ...form })
ElMessage.success('病历已创建')
dialogVisible.value = false
loadPage()
aiData.value = res?.aiSupport || null
aiDialogVisible.value = true
} finally {
saving.value = false
}
}
const viewAi = async (row) => {
aiDialogVisible.value = true
aiLoading.value = true
aiData.value = null
try {
aiData.value = await aiSuggestions(row.id)
} catch {
aiData.value = null
} finally {
aiLoading.value = false
}
}
const remove = async (row) => {
await ElMessageBox.confirm(`删除病历 #${row.id}?`, '确认', { type: 'warning' })
await deleteEmr(row.id)
ElMessage.success('删除成功')
loadPage()
}
onMounted(loadPage)
</script>
<style scoped>
.panel {
border: none;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(6px);
}
.page-tip {
margin-bottom: 14px;
border-radius: 10px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.title {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.sub {
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
}
.tools {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.pager {
margin-top: 16px;
justify-content: flex-end;
display: flex;
}
.section {
margin-bottom: 18px;
}
.section-title {
font-weight: 700;
color: #0f172a;
margin-bottom: 8px;
padding-left: 8px;
border-left: 3px solid #409eff;
}
.meta-row {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.create-form {
width: 100%;
box-sizing: border-box;
}
.create-form :deep(.el-form-item) {
margin-bottom: 12px;
}
.create-form :deep(.el-form-item__label) {
margin-bottom: 6px !important;
line-height: 1.3;
color: #475569;
font-weight: 600;
padding: 0 !important;
}
.form-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 16px;
width: 100%;
box-sizing: border-box;
}
.form-two-col :deep(.el-form-item) {
margin-bottom: 12px;
}
@media (max-width: 560px) {
.form-two-col {
grid-template-columns: 1fr;
}
}
.bullet-list {
margin: 0;
padding-left: 18px;
color: #334155;
line-height: 1.7;
}
</style>
+34
View File
@@ -0,0 +1,34 @@
<template>
<div class="wrap">
<el-result icon="warning" title="403 无权访问" sub-title="当前账号没有访问该页面的权限,请联系管理员或切换账号。">
<template #extra>
<el-button type="primary" @click="$router.push('/dashboard')">返回仪表盘</el-button>
<el-button @click="logout">切换账号</el-button>
</template>
</el-result>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const logout = () => {
userStore.reset()
router.push('/login')
}
</script>
<style scoped>
.wrap {
min-height: 60vh;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border-radius: 14px;
}
</style>
File diff suppressed because it is too large Load Diff
+192
View File
@@ -0,0 +1,192 @@
<template>
<div class="login-wrapper">
<div class="login-bg" />
<div class="login-mask" />
<el-card class="login-card" shadow="always">
<div class="header">
<div class="logo">
<el-icon :size="28"><OfficeBuilding /></el-icon>
</div>
<div>
<h2 class="title">智慧医院管理系统</h2>
<p class="subtitle">患者 · 影像 · 病历 · 预约一体化管理</p>
</div>
</div>
<el-form ref="formRef" :model="form" :rules="rules" size="large" @submit.prevent="onSubmit">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" clearable>
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
show-password
@keyup.enter="onSubmit"
>
<template #prefix><el-icon><Lock /></el-icon></template>
</el-input>
</el-form-item>
<el-button type="primary" class="submit" :loading="loading" @click="onSubmit">
登 录
</el-button>
</el-form>
<div class="accounts">
<div class="accounts-title">测试账号</div>
<div class="account-row">
<el-tag type="danger" effect="plain">管理员</el-tag>
<code>admin / admin123</code>
<el-button link type="primary" @click="fill('admin', 'admin123')">填入</el-button>
</div>
<div class="account-row">
<el-tag type="success" effect="plain">医生</el-tag>
<code>doctor1 / pass123</code>
<el-button link type="primary" @click="fill('doctor1', 'pass123')">填入</el-button>
</div>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock, OfficeBuilding } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const formRef = ref()
const loading = ref(false)
const form = reactive({ username: '', password: '' })
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
const fill = (u, p) => {
form.username = u
form.password = p
}
const onSubmit = async () => {
await formRef.value.validate()
loading.value = true
try {
await userStore.login(form)
ElMessage.success(`欢迎回来,${userStore.displayName}`)
const redirect = route.query.redirect || '/dashboard'
await router.replace(String(redirect))
} catch {
/* 拦截器已提示 */
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-wrapper {
position: relative;
min-height: 100vh;
height: 100vh;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.login-bg {
position: absolute;
inset: 0;
background: url('/images/login-bg.jpg') center / cover no-repeat;
transform: scale(1.02);
z-index: 0;
}
.login-mask {
position: absolute;
inset: 0;
background:
linear-gradient(120deg, rgba(15, 61, 102, 0.72) 0%, rgba(30, 96, 145, 0.55) 45%, rgba(56, 189, 248, 0.35) 100%);
z-index: 1;
}
.login-card {
width: 440px;
max-width: 100%;
border-radius: 18px;
border: none;
position: relative;
z-index: 2;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(10px);
box-shadow: 0 20px 50px rgba(15, 61, 102, 0.35);
}
.header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 24px;
}
.logo {
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #1e6091, #38bdf8);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.title {
margin: 0;
font-size: 18px;
color: #0f172a;
}
.subtitle {
margin: 6px 0 0;
font-size: 12px;
color: #64748b;
}
.submit {
width: 100%;
height: 44px;
font-size: 15px;
letter-spacing: 2px;
}
.accounts {
margin-top: 20px;
padding-top: 16px;
border-top: 1px dashed #e2e8f0;
}
.accounts-title {
font-size: 12px;
color: #94a3b8;
margin-bottom: 10px;
}
.account-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
font-size: 13px;
}
.account-row code {
flex: 1;
background: #f8fafc;
padding: 4px 8px;
border-radius: 6px;
color: #334155;
}
</style>
+454
View File
@@ -0,0 +1,454 @@
<template>
<div class="page">
<el-card shadow="never" class="panel">
<template #header>
<div class="header">
<div>
<div class="title">患者管理</div>
<div class="sub">维护患者基础档案 · 查看 360° 诊疗档案</div>
</div>
<div class="tools">
<el-input
v-model="keyword"
placeholder="按姓名搜索"
clearable
style="width: 220px"
@clear="reload"
@keyup.enter="reload"
>
<template #append>
<el-button @click="reload">搜索</el-button>
</template>
</el-input>
<el-button type="primary" @click="openForm()">新增患者</el-button>
</div>
</div>
</template>
<el-table :data="rows" v-loading="loading" stripe empty-text="暂无患者数据" class="table">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="姓名" min-width="100" />
<el-table-column label="性别" width="80">
<template #default="{ row }">{{ genderLabel(row.gender) }}</template>
</el-table-column>
<el-table-column prop="age" label="年龄" width="80" />
<el-table-column prop="phone" label="电话" min-width="120" />
<el-table-column prop="idCard" label="身份证" min-width="170" show-overflow-tooltip />
<el-table-column prop="address" label="地址" min-width="140" show-overflow-tooltip />
<el-table-column prop="medicalHistory" label="既往病史" min-width="140" show-overflow-tooltip />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="success" @click="openProfile(row)">档案</el-button>
<el-button link type="primary" @click="openForm(row)">编辑</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pager.page"
v-model:page-size="pager.size"
:total="pager.total"
layout="total, prev, pager, next, sizes"
:page-sizes="[10, 20, 50]"
class="pager"
@current-change="loadPage"
@size-change="reload"
/>
</el-card>
<el-dialog
v-model="dialogVisible"
:title="form.id ? '编辑患者' : '新增患者'"
width="520px"
destroy-on-close
align-center
append-to-body
class="form-dialog"
:close-on-click-modal="false"
>
<!-- 顶栏标签 + 等宽两列,左右留白一致(避免 label-width + gutter 偏左) -->
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
require-asterisk-position="right"
class="create-form"
>
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" maxlength="50" placeholder="请输入姓名" />
</el-form-item>
<div class="form-two-col">
<el-form-item label="性别" prop="gender">
<el-select v-model="form.gender" style="width: 100%">
<el-option label="男" value="MALE" />
<el-option label="女" value="FEMALE" />
<el-option label="其他" value="OTHER" />
</el-select>
</el-form-item>
<el-form-item label="年龄" prop="age">
<el-input-number v-model="form.age" :min="0" :max="150" style="width: 100%" />
</el-form-item>
</div>
<el-form-item label="身份证">
<el-input v-model="form.idCard" maxlength="18" placeholder="选填" />
</el-form-item>
<el-form-item label="电话">
<el-input v-model="form.phone" maxlength="20" placeholder="选填" />
</el-form-item>
<el-form-item label="地址">
<el-input v-model="form.address" placeholder="选填" />
</el-form-item>
<el-form-item label="既往病史">
<el-input v-model="form.medicalHistory" type="textarea" :rows="3" placeholder="选填" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" :loading="saving" @click="save">保 存</el-button>
</template>
</el-dialog>
<!-- append-to-body:避免被主布局 overflow:hidden 裁切;body 内滚动 -->
<el-drawer
v-model="profileVisible"
size="min(720px, 96vw)"
destroy-on-close
append-to-body
class="profile-drawer"
:close-on-click-modal="true"
>
<template #header>
<div class="drawer-title">
患者 360° 档案
<el-tag v-if="profile?.patient" size="small" effect="plain" type="primary" style="margin-left: 8px">
{{ profile.patient.name }}
</el-tag>
</div>
</template>
<div v-loading="profileLoading" class="profile-body">
<template v-if="profile?.patient">
<el-descriptions :column="2" border size="small" class="mb16 profile-desc">
<el-descriptions-item label="姓名">{{ profile.patient.name }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ genderLabel(profile.patient.gender) }}</el-descriptions-item>
<el-descriptions-item label="年龄">{{ profile.patient.age }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ profile.patient.phone || '—' }}</el-descriptions-item>
<el-descriptions-item label="身份证" :span="2">{{ profile.patient.idCard || '—' }}</el-descriptions-item>
<el-descriptions-item label="地址" :span="2">{{ profile.patient.address || '—' }}</el-descriptions-item>
<el-descriptions-item label="既往病史" :span="2">{{ profile.patient.medicalHistory || '—' }}</el-descriptions-item>
</el-descriptions>
<div class="mini-stat-row mb16">
<div class="mini-stat">
<span class="mini-label">影像</span>
<b>{{ profile.imagingCount ?? 0 }}</b>
</div>
<div class="mini-stat">
<span class="mini-label">病历</span>
<b>{{ profile.emrCount ?? 0 }}</b>
</div>
<div class="mini-stat">
<span class="mini-label">预约</span>
<b>{{ profile.appointmentCount ?? 0 }}</b>
</div>
</div>
<el-tabs class="profile-tabs">
<el-tab-pane :label="`影像记录 (${profile.imagingCount ?? 0})`">
<el-table :data="profile.imagingRecords || []" size="small" empty-text="暂无影像" max-height="420">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column label="类型" width="90">
<template #default="{ row }">{{ studyTypeLabel(row.studyType) }}</template>
</el-table-column>
<el-table-column prop="bodyPart" label="部位" min-width="80" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="imagingStatusType(row.status)">
{{ imagingStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="aiDiagnosis" label="AI 诊断" min-width="140" show-overflow-tooltip />
</el-table>
</el-tab-pane>
<el-tab-pane :label="`电子病历 (${profile.emrCount ?? 0})`">
<el-table :data="profile.medicalRecords || []" size="small" empty-text="暂无病历" max-height="420">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="visitDate" label="就诊日" width="110" />
<el-table-column prop="doctorName" label="医生" width="90" />
<el-table-column prop="diagnosis" label="诊断" min-width="160" show-overflow-tooltip />
</el-table>
</el-tab-pane>
<el-tab-pane :label="`预约 (${profile.appointmentCount ?? 0})`">
<el-table :data="profile.appointments || []" size="small" empty-text="暂无预约" max-height="420">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="appointmentDate" label="日期" width="110" />
<el-table-column prop="department" label="科室" width="100" />
<el-table-column prop="reason" label="事由" min-width="120" show-overflow-tooltip />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="appointmentStatusType(row.status)">
{{ appointmentStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</template>
<el-empty v-else-if="!profileLoading" description="暂无档案数据" :image-size="80" />
</div>
</el-drawer>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
listPatients,
createPatient,
updatePatient,
deletePatient,
getPatientProfile
} from '@/api/patient'
import {
genderLabel,
studyTypeLabel,
imagingStatusLabel,
imagingStatusType,
appointmentStatusLabel,
appointmentStatusType
} from '@/utils/labels'
const rows = ref([])
const loading = ref(false)
const keyword = ref('')
const pager = reactive({ page: 1, size: 10, total: 0 })
const dialogVisible = ref(false)
const saving = ref(false)
const formRef = ref()
const emptyForm = () => ({
id: null,
name: '',
gender: 'MALE',
age: 30,
idCard: '',
phone: '',
address: '',
medicalHistory: ''
})
const form = reactive(emptyForm())
const rules = {
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
age: [{ required: true, message: '请输入年龄', trigger: 'change' }]
}
const profileVisible = ref(false)
const profileLoading = ref(false)
const profile = ref(null)
const loadPage = async () => {
loading.value = true
try {
const data = await listPatients({
page: pager.page - 1,
size: pager.size,
keyword: keyword.value || undefined
})
rows.value = data?.content || []
pager.total = data?.totalElements || 0
} catch {
rows.value = []
pager.total = 0
} finally {
loading.value = false
}
}
const reload = () => {
pager.page = 1
loadPage()
}
const openForm = (row) => {
Object.assign(form, row ? { ...row } : emptyForm())
dialogVisible.value = true
}
const openProfile = async (row) => {
profileVisible.value = true
profileLoading.value = true
profile.value = null
try {
profile.value = await getPatientProfile(row.id)
} catch {
profile.value = null
} finally {
profileLoading.value = false
}
}
const save = async () => {
await formRef.value.validate()
saving.value = true
try {
if (form.id) await updatePatient(form.id, form)
else await createPatient(form)
ElMessage.success('保存成功')
dialogVisible.value = false
loadPage()
} finally {
saving.value = false
}
}
const remove = async (row) => {
await ElMessageBox.confirm(`确定删除患者「${row.name}」?`, '删除确认', { type: 'warning' })
await deletePatient(row.id)
ElMessage.success('删除成功')
loadPage()
}
onMounted(loadPage)
</script>
<style scoped>
.panel {
border: none;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(6px);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.title {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.sub {
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
}
.tools {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.pager {
margin-top: 16px;
justify-content: flex-end;
display: flex;
}
.drawer-title {
font-weight: 700;
font-size: 16px;
color: #0f172a;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.mb16 {
margin-bottom: 16px;
}
.profile-body {
min-height: 200px;
padding-bottom: 8px;
}
.mini-stat-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.mini-stat {
text-align: center;
padding: 14px 10px;
border-radius: 12px;
background: #f8fafc;
border: 1px solid #eef2f7;
color: #64748b;
font-size: 13px;
}
.mini-label {
display: block;
font-size: 12px;
color: #94a3b8;
}
.mini-stat b {
display: block;
margin-top: 6px;
font-size: 22px;
font-weight: 700;
color: #0f172a;
font-variant-numeric: tabular-nums;
}
.profile-tabs :deep(.el-tabs__header) {
margin-bottom: 12px;
}
/* 弹窗表单:顶栏标签,内容区左右对齐一致 */
.create-form {
width: 100%;
box-sizing: border-box;
}
.create-form :deep(.el-form-item) {
margin-bottom: 14px;
}
.create-form :deep(.el-form-item__label) {
margin-bottom: 6px !important;
line-height: 1.3;
color: #475569;
font-weight: 600;
padding: 0 !important;
}
.form-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 16px;
width: 100%;
box-sizing: border-box;
}
.form-two-col :deep(.el-form-item) {
margin-bottom: 14px;
}
@media (max-width: 520px) {
.form-two-col {
grid-template-columns: 1fr;
}
}
</style>
<!-- drawer 挂到 body,scoped 样式够不着外壳,用非 scoped 补全高度/滚动 -->
<style>
.profile-drawer.el-drawer {
height: 100% !important;
max-height: 100vh !important;
display: flex;
flex-direction: column;
}
.profile-drawer .el-drawer__header {
margin-bottom: 0;
padding: 16px 20px;
border-bottom: 1px solid #eef2f7;
flex-shrink: 0;
}
.profile-drawer .el-drawer__body {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 16px 20px 24px;
box-sizing: border-box;
}
</style>
+840
View File
@@ -0,0 +1,840 @@
<template>
<div class="page">
<!-- 统计 -->
<el-row :gutter="12" class="stat-row">
<el-col :xs="12" :sm="8" :md="4" v-for="c in statCards" :key="c.key">
<div class="stat-card" :class="[{ active: c.active }, c.key]" @click="applyStat(c)">
<div class="stat-label">{{ c.label }}</div>
<div class="stat-value">{{ c.value }}</div>
</div>
</el-col>
</el-row>
<el-card shadow="never" class="panel">
<template #header>
<div class="header">
<div>
<div class="title">用户管理</div>
<div class="sub">账号、角色、头像与启用状态维护</div>
</div>
<el-button type="primary" @click="openForm()">
<el-icon class="btn-icon"><Plus /></el-icon>
新增用户
</el-button>
</div>
</template>
<div class="filter-bar">
<el-radio-group v-model="filters.role" size="default" @change="reload">
<el-radio-button value="">全部角色</el-radio-button>
<el-radio-button value="ADMIN">管理员</el-radio-button>
<el-radio-button value="DOCTOR">医生</el-radio-button>
<el-radio-button value="RADIOLOGIST">影像医师</el-radio-button>
</el-radio-group>
<el-radio-group v-model="filters.enabled" size="default" @change="reload">
<el-radio-button value="">全部状态</el-radio-button>
<el-radio-button value="true">启用</el-radio-button>
<el-radio-button value="false">停用</el-radio-button>
</el-radio-group>
<div class="filter-right">
<el-input
v-model="filters.keyword"
placeholder="账号 / 姓名 / 科室 / 电话"
clearable
style="width: 240px"
@clear="reload"
@keyup.enter="reload"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button @click="reload">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</div>
<el-table :data="rows" v-loading="loading" stripe empty-text="暂无用户" class="user-table">
<el-table-column prop="id" label="ID" width="64" />
<el-table-column label="用户" min-width="200">
<template #default="{ row }">
<div class="user-cell">
<el-avatar :size="40" class="row-avatar" :src="row.avatar || undefined">
{{ (row.realName || row.username || 'U').slice(0, 1) }}
</el-avatar>
<div class="user-meta">
<div class="name-line">
<span class="name">{{ row.realName || '—' }}</span>
<el-tag v-if="isSelf(row)" size="small" type="warning" effect="plain">当前</el-tag>
</div>
<div class="username">@{{ row.username }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="角色" width="120">
<template #default="{ row }">
<el-tag :type="roleTagType(row.role)" effect="light" round>
{{ roleLabel(row.role) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="department" label="科室" min-width="100">
<template #default="{ row }">
<span>{{ row.department || '—' }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-switch
:model-value="row.enabled !== false"
:disabled="isSelf(row) && row.enabled !== false"
inline-prompt
active-text="启"
inactive-text="停"
@change="(v) => onSwitchEnabled(row, v)"
/>
</template>
</el-table-column>
<el-table-column prop="phone" label="电话" min-width="120">
<template #default="{ row }">{{ row.phone || '—' }}</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.email || '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right" align="left">
<template #default="{ row }">
<div class="ops">
<el-button link type="primary" class="op-btn" @click="openForm(row)">编辑</el-button>
<el-button link type="success" class="op-btn" @click="openAvatar(row)">头像</el-button>
<el-dropdown trigger="click" class="op-drop" @command="(cmd) => onMore(row, cmd)">
<el-button link type="info" class="op-btn">更多</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="detail">查看详情</el-dropdown-item>
<el-dropdown-item
v-if="row.enabled === false"
command="enable"
>
启用账号
</el-dropdown-item>
<el-dropdown-item
v-else
command="disable"
:disabled="isSelf(row)"
>
停用账号
</el-dropdown-item>
<el-dropdown-item
command="delete"
divided
:disabled="isSelf(row)"
>
<span class="danger">删除用户</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pager.page"
v-model:page-size="pager.size"
:total="pager.total"
layout="total, prev, pager, next, sizes"
:page-sizes="[10, 20, 50]"
class="pager"
@current-change="loadPage"
@size-change="reload"
/>
</el-card>
<!-- 新增 / 编辑 -->
<el-dialog
v-model="dialogVisible"
:title="form.id ? '编辑用户' : '新增用户'"
width="560px"
destroy-on-close
align-center
append-to-body
class="form-dialog"
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="账号" prop="username">
<el-input
v-model="form.username"
:disabled="!!form.id"
maxlength="50"
placeholder="登录用户名"
/>
</el-form-item>
<el-form-item label="密码" :prop="form.id ? undefined : 'password'">
<el-input
v-model="form.password"
:placeholder="form.id ? '留空则不修改密码' : '至少 6 位'"
show-password
maxlength="64"
/>
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="姓名" prop="realName">
<el-input v-model="form.realName" placeholder="真实姓名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="角色" prop="role">
<el-select v-model="form.role" style="width: 100%">
<el-option label="医生" value="DOCTOR" />
<el-option label="影像医师" value="RADIOLOGIST" />
<el-option label="管理员" value="ADMIN" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="科室">
<el-select
v-model="form.department"
filterable
allow-create
default-first-option
clearable
placeholder="选择或输入科室"
style="width: 100%"
>
<el-option v-for="d in departments" :key="d" :label="d" :value="d" />
</el-select>
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="电话">
<el-input v-model="form.phone" maxlength="20" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="邮箱">
<el-input v-model="form.email" maxlength="80" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="状态">
<el-switch
v-model="form.enabled"
:disabled="isSelfForm"
active-text="启用"
inactive-text="停用"
/>
<span v-if="isSelfForm" class="form-tip">不能停用当前登录账号</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" :loading="saving" @click="save">保 存</el-button>
</template>
</el-dialog>
<!-- 头像 -->
<el-dialog v-model="avatarVisible" title="设置用户头像" width="420px" destroy-on-close align-center append-to-body>
<div class="avatar-panel">
<el-avatar :size="96" class="row-avatar preview" :src="avatarPreview || avatarTarget?.avatar || undefined">
{{ (avatarTarget?.realName || avatarTarget?.username || 'U').slice(0, 1) }}
</el-avatar>
<div class="avatar-user">
{{ avatarTarget?.realName }}
<span class="uname">@{{ avatarTarget?.username }}</span>
</div>
<el-upload
:show-file-list="false"
:auto-upload="false"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp"
:on-change="onAvatarPick"
>
<el-button type="primary" plain>选择图片</el-button>
</el-upload>
<div class="avatar-tip">支持 jpg / png / gif / webp,不超过 2MB</div>
</div>
<template #footer>
<el-button @click="avatarVisible = false">取 消</el-button>
<el-button type="primary" :loading="avatarSaving" :disabled="!avatarFile" @click="submitAvatar">
上 传
</el-button>
</template>
</el-dialog>
<!-- 详情 -->
<el-drawer v-model="detailVisible" title="用户详情" size="400px" destroy-on-close append-to-body>
<template v-if="detail">
<div class="detail-head">
<el-avatar :size="72" class="row-avatar" :src="detail.avatar || undefined">
{{ (detail.realName || detail.username || 'U').slice(0, 1) }}
</el-avatar>
<div>
<div class="detail-name">
{{ detail.realName }}
<el-tag v-if="isSelf(detail)" size="small" type="warning" effect="plain">当前</el-tag>
</div>
<div class="detail-user">@{{ detail.username }}</div>
<el-tag :type="roleTagType(detail.role)" size="small" effect="light">
{{ roleLabel(detail.role) }}
</el-tag>
</div>
</div>
<el-descriptions :column="1" border class="detail-desc">
<el-descriptions-item label="用户 ID">{{ detail.id }}</el-descriptions-item>
<el-descriptions-item label="科室">{{ detail.department || '—' }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ detail.phone || '—' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ detail.email || '—' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="detail.enabled === false ? 'danger' : 'success'" size="small">
{{ detail.enabled === false ? '已停用' : '启用' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="detail-actions">
<el-button type="primary" @click="openForm(detail); detailVisible = false">编辑</el-button>
<el-button @click="openAvatar(detail); detailVisible = false">改头像</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import {
listUsers,
getUserStats,
createUser,
updateUser,
deleteUser,
setUserEnabled,
uploadUserAvatar
} from '@/api/user'
import { useUserStore } from '@/stores/user'
import { roleLabel, roleTagType } from '@/utils/labels'
const userStore = useUserStore()
const rows = ref([])
const loading = ref(false)
const pager = reactive({ page: 1, size: 10, total: 0 })
const filters = reactive({ keyword: '', role: '', enabled: '' })
const stats = ref({
total: 0,
adminCount: 0,
doctorCount: 0,
radiologistCount: 0,
enabledCount: 0,
disabledCount: 0
})
const departments = ['内科', '外科', '影像科', '儿科', '妇科', '骨科', '急诊科', '信息科', '心内科', '神经内科']
const isSelf = (row) =>
!!row &&
(row.id === userStore.userInfo?.id || row.username === userStore.userInfo?.username)
const isSelfForm = computed(() => form.id && form.id === userStore.userInfo?.id)
const statCards = computed(() => [
{
key: 'total',
label: '全部用户',
value: stats.value.total ?? 0,
active: !filters.role && !filters.enabled,
clear: true
},
{
key: 'admin',
label: '管理员',
value: stats.value.adminCount ?? 0,
active: filters.role === 'ADMIN',
role: 'ADMIN'
},
{
key: 'doctor',
label: '医生',
value: stats.value.doctorCount ?? 0,
active: filters.role === 'DOCTOR',
role: 'DOCTOR'
},
{
key: 'radio',
label: '影像医师',
value: stats.value.radiologistCount ?? 0,
active: filters.role === 'RADIOLOGIST',
role: 'RADIOLOGIST'
},
{
key: 'on',
label: '已启用',
value: stats.value.enabledCount ?? 0,
active: filters.enabled === 'true',
enabled: 'true'
},
{
key: 'off',
label: '已停用',
value: stats.value.disabledCount ?? 0,
active: filters.enabled === 'false',
enabled: 'false'
}
])
const dialogVisible = ref(false)
const saving = ref(false)
const formRef = ref()
const emptyForm = () => ({
id: null,
username: '',
password: '',
realName: '',
role: 'DOCTOR',
department: '内科',
phone: '',
email: '',
enabled: true
})
const form = reactive(emptyForm())
const rules = {
username: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 3, max: 50, message: '账号长度 3-50', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 位', trigger: 'blur' }
],
realName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
role: [{ required: true, message: '请选择角色', trigger: 'change' }]
}
const avatarVisible = ref(false)
const avatarSaving = ref(false)
const avatarTarget = ref(null)
const avatarFile = ref(null)
const avatarPreview = ref('')
const detailVisible = ref(false)
const detail = ref(null)
const loadStats = async () => {
try {
stats.value = (await getUserStats()) || stats.value
} catch {
/* ignore */
}
}
const loadPage = async () => {
loading.value = true
try {
let enabled
if (filters.enabled === 'true') enabled = true
else if (filters.enabled === 'false') enabled = false
const data = await listUsers({
page: pager.page - 1,
size: pager.size,
keyword: filters.keyword || undefined,
role: filters.role || undefined,
enabled
})
rows.value = data?.content || []
pager.total = data?.totalElements || 0
} catch {
rows.value = []
pager.total = 0
} finally {
loading.value = false
}
}
const reload = () => {
pager.page = 1
loadPage()
loadStats()
}
const resetFilters = () => {
filters.keyword = ''
filters.role = ''
filters.enabled = ''
reload()
}
const applyStat = (c) => {
if (c.clear) {
resetFilters()
return
}
if (c.role) {
filters.role = c.role
filters.enabled = ''
}
if (c.enabled !== undefined) {
filters.enabled = c.enabled
filters.role = ''
}
reload()
}
const openForm = (row) => {
if (row) {
Object.assign(form, {
id: row.id,
username: row.username,
password: '',
realName: row.realName || '',
role: row.role || 'DOCTOR',
department: row.department || '',
phone: row.phone || '',
email: row.email || '',
enabled: row.enabled !== false
})
} else {
Object.assign(form, emptyForm())
}
dialogVisible.value = true
}
const save = async () => {
await formRef.value.validate()
if (isSelfForm.value && form.enabled === false) {
ElMessage.warning('不能停用当前登录账号')
return
}
saving.value = true
try {
const payload = {
username: form.username,
realName: form.realName,
role: form.role,
department: form.department,
phone: form.phone,
email: form.email,
enabled: form.enabled,
password: form.password || undefined
}
if (form.id && !payload.password) delete payload.password
if (form.id) await updateUser(form.id, payload)
else await createUser(payload)
ElMessage.success('保存成功')
dialogVisible.value = false
reload()
} finally {
saving.value = false
}
}
const openAvatar = (row) => {
avatarTarget.value = row
avatarFile.value = null
if (avatarPreview.value) URL.revokeObjectURL(avatarPreview.value)
avatarPreview.value = ''
avatarVisible.value = true
}
const onAvatarPick = (file) => {
const raw = file.raw
if (!raw) return
if (!raw.type?.startsWith('image/')) {
ElMessage.warning('请选择图片文件')
return
}
if (raw.size > 2 * 1024 * 1024) {
ElMessage.warning('图片不能超过 2MB')
return
}
avatarFile.value = raw
if (avatarPreview.value) URL.revokeObjectURL(avatarPreview.value)
avatarPreview.value = URL.createObjectURL(raw)
}
const submitAvatar = async () => {
if (!avatarFile.value || !avatarTarget.value) return
avatarSaving.value = true
try {
const user = await uploadUserAvatar(avatarTarget.value.id, avatarFile.value)
ElMessage.success('头像已更新')
avatarVisible.value = false
if (userStore.userInfo?.id === user.id) {
userStore.setUserInfo({ ...userStore.userInfo, ...user })
}
loadPage()
} finally {
avatarSaving.value = false
}
}
const onSwitchEnabled = async (row, next) => {
if (isSelf(row) && !next) {
ElMessage.warning('不能停用当前登录账号')
return
}
const action = next ? '启用' : '停用'
try {
await ElMessageBox.confirm(`确定${action}账号「${row.username}」?`, '确认', { type: 'warning' })
await setUserEnabled(row.id, next)
ElMessage.success(`已${action}`)
reload()
} catch {
/* 取消 */
}
}
const onMore = async (row, cmd) => {
if (cmd === 'detail') {
detail.value = { ...row }
detailVisible.value = true
return
}
if (cmd === 'enable') {
await onSwitchEnabled(row, true)
return
}
if (cmd === 'disable') {
await onSwitchEnabled(row, false)
return
}
if (cmd === 'delete') {
await remove(row)
}
}
const remove = async (row) => {
if (isSelf(row)) {
ElMessage.warning('不能删除当前登录账号')
return
}
await ElMessageBox.confirm(
`确定删除用户「${row.realName || row.username}」?此操作不可恢复。`,
'删除确认',
{ type: 'warning', confirmButtonText: '删除' }
)
await deleteUser(row.id)
ElMessage.success('删除成功')
reload()
}
onMounted(() => {
loadPage()
loadStats()
})
</script>
<style scoped>
.stat-row {
margin-bottom: 4px;
}
.stat-card {
background: rgba(255, 255, 255, 0.94);
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 12px;
border: 1px solid #eef2f7;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(15, 61, 102, 0.08);
}
.stat-card.active {
border-color: #409eff;
box-shadow: 0 0 0 1px rgba(64, 158, 255, 0.2);
}
.stat-card.total { border-top: 3px solid #1e6091; }
.stat-card.admin { border-top: 3px solid #f56c6c; }
.stat-card.doctor { border-top: 3px solid #67c23a; }
.stat-card.radio { border-top: 3px solid #e6a23c; }
.stat-card.on { border-top: 3px solid #409eff; }
.stat-card.off { border-top: 3px solid #909399; }
.stat-label {
font-size: 12px;
color: #64748b;
}
.stat-value {
margin-top: 4px;
font-size: 24px;
font-weight: 700;
color: #0f172a;
line-height: 1.1;
}
.panel {
border: none;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
backdrop-filter: blur(6px);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.title {
font-size: 16px;
font-weight: 700;
color: #0f172a;
}
.sub {
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
}
.btn-icon {
margin-right: 4px;
vertical-align: middle;
}
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.filter-right {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-left: auto;
align-items: center;
}
.user-cell {
display: flex;
align-items: center;
gap: 12px;
}
.user-meta {
min-width: 0;
line-height: 1.35;
}
.name-line {
display: flex;
align-items: center;
gap: 6px;
}
.name {
font-weight: 600;
color: #0f172a;
}
.username {
font-size: 12px;
color: #94a3b8;
}
.row-avatar {
background: linear-gradient(135deg, #1e6091, #38bdf8);
color: #fff;
font-weight: 600;
flex-shrink: 0;
}
.danger {
color: #f56c6c;
}
/* 操作列:统一水平/垂直对齐,避免 dropdown 把「更多」顶高 */
.ops {
display: inline-flex;
align-items: center;
gap: 2px;
height: 32px;
white-space: nowrap;
vertical-align: middle;
}
.ops :deep(.op-btn) {
margin: 0 !important;
padding: 0 6px !important;
height: 28px !important;
line-height: 28px !important;
font-size: 13px;
vertical-align: middle;
}
.ops :deep(.op-drop) {
display: inline-flex;
align-items: center;
height: 28px;
line-height: 28px;
vertical-align: middle;
}
.ops :deep(.el-dropdown) {
line-height: 28px;
vertical-align: middle;
}
.pager {
margin-top: 16px;
justify-content: flex-end;
display: flex;
}
.form-tip {
margin-left: 10px;
font-size: 12px;
color: #94a3b8;
}
.avatar-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 8px 0;
}
.avatar-panel .preview {
font-size: 32px;
}
.avatar-user {
font-size: 15px;
color: #0f172a;
font-weight: 600;
}
.avatar-user .uname {
margin-left: 6px;
font-weight: 400;
color: #94a3b8;
font-size: 13px;
}
.avatar-tip {
font-size: 12px;
color: #94a3b8;
}
.detail-head {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 18px;
}
.detail-name {
font-size: 18px;
font-weight: 700;
color: #0f172a;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.detail-user {
font-size: 13px;
color: #94a3b8;
margin-bottom: 8px;
}
.detail-desc {
margin-bottom: 18px;
}
.detail-actions {
display: flex;
gap: 8px;
}
</style>
+37
View File
@@ -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
}
}
}
})
+6
View File
@@ -0,0 +1,6 @@
{
"name": "smart-hospital",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Some files were not shown because too many files have changed in this diff Show More