26-7-31-1
@@ -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
|
||||
@@ -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 完整度**方面都存在明显扩展空间。
|
||||
|
||||
**非常适合作为二次开发或课程改造的起点。**
|
||||
@@ -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. 右上角用户菜单 → 修改密码 → 成功后需重新登录
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.env
|
||||
data/
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.png
|
||||
!samples/.gitkeep
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -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`,由后端转发到本服务。
|
||||
|
||||
## 声明
|
||||
|
||||
输出仅供教学实训与辅助决策演示,**不能替代执业医师诊断**。
|
||||
@@ -0,0 +1 @@
|
||||
"""Smart Hospital AI Service."""
|
||||
@@ -0,0 +1 @@
|
||||
"""API routers."""
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()}
|
||||
@@ -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)
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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: 消化外科
|
||||
|
||||
## 表现
|
||||
右上腹痛、油腻饮食后加重,可伴恶心;超声是首选检查。
|
||||
|
||||
## 处理
|
||||
- 无症状结石可观察
|
||||
- 症状性结石评估腹腔镜胆囊切除指征
|
||||
- 合并胆管炎/胰腺炎需紧急处理
|
||||
|
||||
## 护理与饮食
|
||||
- 低脂饮食
|
||||
- 观察腹痛、黄疸、发热
|
||||
- 术后早期活动
|
||||
|
||||
## 注意
|
||||
实训演示用,非临床处方依据。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 2 型糖尿病辅助管理要点
|
||||
|
||||
category: 内分泌
|
||||
|
||||
## 诊断要点
|
||||
空腹血糖、OGTT 或 HbA1c 达到诊断阈值;需排除 1 型及其他特殊类型。
|
||||
|
||||
## 综合管理
|
||||
- 医学营养治疗与运动
|
||||
- 血糖自我监测
|
||||
- 个体化降糖药物(如二甲双胍等,注意禁忌)
|
||||
- 血压、血脂与抗血小板综合干预
|
||||
|
||||
## 并发症筛查
|
||||
- 视网膜病变、肾病、神经病变、足病
|
||||
- 心血管风险评估
|
||||
|
||||
## 护理与随访
|
||||
- 低血糖识别与处理
|
||||
- 足部护理
|
||||
- 定期复查血糖与 HbA1c
|
||||
|
||||
## 注意
|
||||
本资料用于教学演示,临床决策需结合指南与患者情况。
|
||||
@@ -0,0 +1,20 @@
|
||||
# 骨折影像与处置要点(示意)
|
||||
|
||||
category: 骨科
|
||||
|
||||
## 影像
|
||||
- X 线是基础检查;复杂部位可 CT/MRI。
|
||||
- 描述骨折部位、类型、移位、关节受累。
|
||||
|
||||
## 处理原则
|
||||
- 复位、固定、功能锻炼
|
||||
- 开放伤注意清创抗感染
|
||||
- 评估血管神经损伤
|
||||
|
||||
## 护理与随访
|
||||
- 患肢肿胀、血运、感觉观察
|
||||
- 疼痛管理与防深静脉血栓
|
||||
- 按医嘱复查 X 光评估愈合
|
||||
|
||||
## 注意
|
||||
仅供实训演示,不作为临床操作依据。
|
||||
@@ -0,0 +1,17 @@
|
||||
# 电子病历与辅助决策书写规范(示意)
|
||||
|
||||
category: 病历质控
|
||||
|
||||
## 病历要素
|
||||
主诉、现病史、体格检查、辅助检查、诊断、治疗计划、用药、随访。
|
||||
|
||||
## AI 辅助使用原则
|
||||
- AI 输出仅供参考,必须经医师审核修改后入档。
|
||||
- 引用知识库内容时注明来源类别。
|
||||
- 避免将模型幻觉内容写入正式病历。
|
||||
|
||||
## 护理建议常见结构
|
||||
病情观察、用药护理、生活指导、心理支持、健康宣教。
|
||||
|
||||
## 随访计划
|
||||
复诊时间、复查项目、危险症状返院指征。
|
||||
@@ -0,0 +1,24 @@
|
||||
# 原发性高血压辅助管理要点
|
||||
|
||||
category: 心血管
|
||||
|
||||
## 诊断
|
||||
非同日三次诊室血压 ≥140/90 mmHg 可诊断高血压;鼓励家庭血压与动态血压监测。
|
||||
|
||||
## 非药物治疗
|
||||
- 限盐(<5 g/日)、DASH 饮食
|
||||
- 规律有氧运动、控制体重
|
||||
- 戒烟限酒、减少精神压力
|
||||
|
||||
## 药物治疗(示意)
|
||||
- 常用类别:ACEI/ARB、CCB、利尿剂、β 受体阻滞剂等。
|
||||
- 合并糖尿病、慢性肾病等需个体化选药。
|
||||
- 注意体位性低血压与电解质紊乱。
|
||||
|
||||
## 护理与随访
|
||||
- 规范测量并记录血压
|
||||
- 提高服药依从性宣教
|
||||
- 评估靶器官损害,定期复诊
|
||||
|
||||
## 注意
|
||||
用药方案必须由执业医师开具,本文仅供实训演示。
|
||||
@@ -0,0 +1,22 @@
|
||||
# 颈椎/腰椎间盘突出 MRI 辅助解读要点
|
||||
|
||||
category: 骨科/影像
|
||||
|
||||
## 常见表现
|
||||
- 椎间盘信号改变、突出压迫硬膜囊或神经根
|
||||
- 可伴椎管狭窄、黄韧带肥厚
|
||||
|
||||
## 临床相关
|
||||
- 颈肩痛、上肢放射痛、腰痛、下肢放射痛、麻木
|
||||
- 需与脊髓病、肿瘤、感染鉴别
|
||||
|
||||
## 治疗路径(示意)
|
||||
- 多数可先保守:休息、理疗、药物、功能锻炼
|
||||
- 神经功能障碍加重或马尾症状需紧急评估手术指征
|
||||
|
||||
## 随访
|
||||
- 症状变化记录
|
||||
- 必要时复查 MRI
|
||||
|
||||
## 注意
|
||||
教学演示资料,诊断以影像科与临床医师为准。
|
||||
@@ -0,0 +1,23 @@
|
||||
# 社区获得性肺炎辅助诊疗要点
|
||||
|
||||
category: 呼吸内科
|
||||
|
||||
## 概述
|
||||
社区获得性肺炎(CAP)是指在医院外罹患的肺实质感染,常见症状包括发热、咳嗽、咳痰、胸痛与呼吸困难。
|
||||
|
||||
## 影像表现
|
||||
- X 线/CT:片状、斑片状浸润影或实变,可伴空气支气管征。
|
||||
- 需与肺结核、肿瘤、肺水肿等鉴别。
|
||||
|
||||
## 治疗原则
|
||||
- 评估 CURB-65 或 PSI 严重程度分层。
|
||||
- 经验性抗感染覆盖常见病原(肺炎链球菌等),并根据培养结果调整。
|
||||
- 支持治疗:氧疗、补液、退热、止咳化痰。
|
||||
|
||||
## 护理与随访
|
||||
- 监测体温、呼吸频率、血氧饱和度。
|
||||
- 鼓励有效咳嗽与体位引流。
|
||||
- 治疗后 3–5 天评估疗效;必要时复查胸片。
|
||||
|
||||
## 注意
|
||||
本资料仅供教学演示与辅助决策参考,不能替代临床指南与医师判断。
|
||||
@@ -0,0 +1,23 @@
|
||||
# 肺结节随访与管理要点
|
||||
|
||||
category: 呼吸/胸外
|
||||
|
||||
## 定义
|
||||
肺结节通常指直径 ≤3 cm 的圆形或类圆形 dens 灶,需结合大小、形态、密度及随访变化综合判断。
|
||||
|
||||
## 影像关注点
|
||||
- 大小与倍增时间
|
||||
- 边缘(毛刺、分叶)、钙化、空泡、胸膜牵拉
|
||||
- 实性 / 部分实性 / 磨玻璃
|
||||
|
||||
## 管理建议(示意)
|
||||
- 微小结节:定期 CT 随访。
|
||||
- 可疑恶性特征:建议专科评估,必要时 PET、活检或多学科讨论。
|
||||
- 戒烟是重要干预措施。
|
||||
|
||||
## 护理与宣教
|
||||
- 缓解患者焦虑,说明随访意义。
|
||||
- 出现咯血、胸痛、进行性气促及时就诊。
|
||||
|
||||
## 注意
|
||||
本资料仅供教学演示,实际随访间隔需按最新指南与个体情况确定。
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""AI service implementations."""
|
||||
@@ -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
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:遵循原仓库许可证,仅作科研/教学
|
||||
- 仿真图:项目内生成,无患者隐私
|
||||
- **禁止将本素材与系统输出用于真实诊疗**
|
||||
@@ -0,0 +1 @@
|
||||
[ ]
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env.local
|
||||
.vite
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 203 KiB |
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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}`)
|
||||
@@ -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' }
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}`)
|
||||
@@ -0,0 +1,4 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const getOverview = () => request.get('/stats/overview')
|
||||
export const fetchOverview = getOverview
|
||||
@@ -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' }
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br/>'))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "smart-hospital",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 45 KiB |