对项目进行了简化

This commit is contained in:
williamWan 2025-10-30 10:18:40 +08:00
parent f4af080b04
commit 442e3a2e57
52 changed files with 4227 additions and 2167 deletions

5
.env
View File

@ -10,8 +10,9 @@ SPRING_PROFILES_ACTIVE=prod
JWT_SECRET=your-secret-key-change-in-production-environment
# Dify配置
# 请将下面的API Key替换为您实际的Dify API Key
DIFY_API_URL=https://api.dify.ai/v1
DIFY_API_KEY=your-dify-api-key-here
DIFY_API_KEY=app-croZF0SSV5fiyXbK3neGrOT6
# 大模型配置
LLM_API_URL=https://api.openai.com/v1
@ -29,3 +30,5 @@ CNKI_PASSWORD=
# 文献下载路径
LITERATURE_DOWNLOAD_PATH=./downloads

View File

@ -1,406 +0,0 @@
# 医学信息支持系统 - 部署说明
## 环境要求
### 开发环境
- **JDK**: 17 或更高版本
- **Node.js**: 18 或更高版本
- **Maven**: 3.8 或更高版本
- **MySQL**: 8.0 或更高版本
### 生产环境
- **Docker**: 20.10 或更高版本
- **Docker Compose**: 2.0 或更高版本
## 快速开始使用Docker
### 1. 克隆项目
```bash
git clone <repository-url>
cd 文献流程
```
### 2. 配置环境变量
复制环境变量示例文件并修改配置:
```bash
cp .env.example .env
```
编辑 `.env` 文件,配置以下关键信息:
```env
# 数据库密码
MYSQL_ROOT_PASSWORD=your-secure-password
MYSQL_PASSWORD=your-secure-password
# JWT密钥生产环境必须修改
JWT_SECRET=your-very-secure-jwt-secret-key
# Dify API配置
DIFY_API_KEY=your-dify-api-key
# 大模型API配置
LLM_API_KEY=your-llm-api-key
```
### 3. 启动服务
```bash
docker-compose up -d
```
### 4. 访问系统
- **前端地址**: http://localhost
- **后端API**: http://localhost:8080/api
- **默认账号**: admin / admin123
### 5. 查看日志
```bash
# 查看所有服务日志
docker-compose logs -f
# 查看特定服务日志
docker-compose logs -f backend
docker-compose logs -f frontend
```
### 6. 停止服务
```bash
docker-compose down
```
## 本地开发部署
### 后端开发
#### 1. 准备MySQL数据库
```bash
# 启动MySQL
mysql -u root -p
# 执行数据库脚本
source database/schema.sql
source database/sample_data.sql
```
#### 2. 配置application.yml
编辑 `backend/src/main/resources/application.yml`
```yaml
spring:
datasource:
url: jdbc:mysql://localhost:3306/medical_info_system
username: your-username
password: your-password
```
#### 3. 启动后端服务
```bash
cd backend
mvn clean install
mvn spring-boot:run
```
后端服务将在 http://localhost:8080 启动。
#### 4. API文档
启动后访问http://localhost:8080/api/swagger-ui.html
### 前端开发
#### 1. 安装依赖
```bash
cd frontend
npm install
```
#### 2. 启动开发服务器
```bash
npm run dev
```
前端开发服务器将在 http://localhost:3000 启动。
#### 3. 构建生产版本
```bash
npm run build
```
构建结果将输出到 `frontend/dist` 目录。
## 生产环境部署
### 方式一使用Docker Compose推荐
1. 按照"快速开始"部分的步骤操作
2. 确保配置正确的环境变量
3. 建议配置反向代理如Nginx并启用HTTPS
### 方式二:手动部署
#### 后端部署
```bash
cd backend
mvn clean package -DskipTests
java -jar target/medical-info-system-1.0.0.jar --spring.profiles.active=prod
```
#### 前端部署
```bash
cd frontend
npm run build
# 将dist目录内容部署到Nginx
cp -r dist/* /usr/share/nginx/html/
```
#### Nginx配置示例
```nginx
server {
listen 80;
server_name your-domain.com;
# 前端静态文件
root /usr/share/nginx/html;
index index.html;
# 前端路由
location / {
try_files $uri $uri/ /index.html;
}
# 后端API代理
location /api/ {
proxy_pass http://localhost:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
## 配置说明
### 后端配置
主配置文件:`backend/src/main/resources/application.yml`
```yaml
app:
# JWT配置
jwt:
secret: ${JWT_SECRET} # JWT密钥
expiration: 86400000 # 过期时间(毫秒)
# Dify配置
dify:
api-url: ${DIFY_API_URL}
api-key: ${DIFY_API_KEY}
# 大模型配置
llm:
api-url: ${LLM_API_URL}
api-key: ${LLM_API_KEY}
model: ${LLM_MODEL}
# 文献下载配置
literature:
download-path: ${LITERATURE_DOWNLOAD_PATH}
accounts:
pubmed:
username: ${PUBMED_USERNAME}
password: ${PUBMED_PASSWORD}
embase:
username: ${EMBASE_USERNAME}
password: ${EMBASE_PASSWORD}
cnki:
username: ${CNKI_USERNAME}
password: ${CNKI_PASSWORD}
```
### 前端配置
代理配置:`frontend/vite.config.js`
```javascript
export default defineConfig({
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
```
## 数据库管理
### 备份数据库
```bash
# 使用Docker
docker exec medical-info-mysql mysqldump -u root -p medical_info_system > backup.sql
# 本地MySQL
mysqldump -u root -p medical_info_system > backup.sql
```
### 恢复数据库
```bash
# 使用Docker
docker exec -i medical-info-mysql mysql -u root -p medical_info_system < backup.sql
# 本地MySQL
mysql -u root -p medical_info_system < backup.sql
```
## 监控与日志
### 应用日志
后端日志位置:
- Docker部署`./logs/`
- 本地开发:控制台输出
### Docker容器监控
```bash
# 查看容器状态
docker-compose ps
# 查看资源使用
docker stats
# 查看容器日志
docker-compose logs -f [service-name]
```
## 常见问题
### 1. 数据库连接失败
**问题**: 后端无法连接数据库
**解决方案**:
- 检查MySQL服务是否启动
- 确认数据库用户名和密码是否正确
- 检查防火墙设置
- 如使用Docker确保容器在同一网络中
### 2. 前端无法访问后端API
**问题**: 前端请求后端API时出现跨域错误
**解决方案**:
- 检查后端CORS配置
- 确认API代理配置正确
- 检查后端服务是否正常运行
### 3. 文献下载失败
**问题**: 系统无法下载文献
**解决方案**:
- 检查文献数据库账号配置是否正确
- 确认下载路径是否有写入权限
- 查看后端日志了解具体错误信息
### 4. Dify API调用失败
**问题**: AI功能无法正常使用
**解决方案**:
- 确认Dify API Key配置正确
- 检查网络连接是否正常
- 查看API配额是否用完
## 性能优化
### 数据库优化
```sql
-- 创建必要的索引
CREATE INDEX idx_inquiry_status ON inquiry_requests(status);
CREATE INDEX idx_inquiry_created_at ON inquiry_requests(created_at);
CREATE INDEX idx_literature_inquiry ON literatures(inquiry_request_id);
```
### 应用优化
1. **后端**:
- 启用数据库连接池
- 配置适当的JVM参数
- 使用Redis缓存热点数据
2. **前端**:
- 启用Gzip压缩
- 使用CDN加速静态资源
- 实现路由懒加载
### Docker优化
```yaml
# docker-compose.yml中添加资源限制
services:
backend:
deploy:
resources:
limits:
cpus: '2'
memory: 2G
```
## 安全建议
1. **生产环境必须修改**:
- 数据库密码
- JWT密钥
- 默认管理员密码
2. **启用HTTPS**:
- 配置SSL证书
- 强制HTTPS访问
3. **定期更新**:
- 及时更新依赖包
- 修复安全漏洞
4. **访问控制**:
- 配置防火墙规则
- 限制数据库访问
- 实施IP白名单
## 技术支持
如遇到问题,请:
1. 查看日志文件获取详细错误信息
2. 参考本文档的常见问题部分
3. 联系技术支持团队
## 更新日志
### v1.0.0 (2024-10-26)
- 初始版本发布
- 实现核心功能模块
- 支持Docker部署

View File

@ -1,77 +0,0 @@
# syntax=docker/dockerfile:1
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Dockerfile reference guide at
# https://docs.docker.com/go/dockerfile-reference/
# Want to help us make this template better? Share your feedback here: https://forms.gle/ybq9Krt8jtBL3iCk7
################################################################################
# Create a stage for resolving and downloading dependencies.
FROM eclipse-temurin:8-jdk-jammy as deps
WORKDIR /build
# Copy the mvnw wrapper with executable permissions.
COPY --chmod=0755 mvnw mvnw
COPY .mvn/ .mvn/
# Download dependencies as a separate step to take advantage of Docker's caching.
# Leverage a cache mount to /root/.m2 so that subsequent builds don't have to
# re-download packages.
RUN --mount=type=bind,source=pom.xml,target=pom.xml \
--mount=type=cache,target=/root/.m2 ./mvnw dependency:go-offline -DskipTests
################################################################################
# Create a stage for building the application based on the stage with downloaded dependencies.
# This Dockerfile is optimized for Java applications that output an uber jar, which includes
# all the dependencies needed to run your app inside a JVM. If your app doesn't output an uber
# jar and instead relies on an application server like Apache Tomcat, you'll need to update this
# stage with the correct filename of your package and update the base image of the "final" stage
# use the relevant app server, e.g., using tomcat (https://hub.docker.com/_/tomcat/) as a base image.
FROM deps as package
WORKDIR /build
COPY ./ src/
RUN --mount=type=bind,source=pom.xml,target=pom.xml \
--mount=type=cache,target=/root/.m2 \
./mvnw package -DskipTests && \
mv target/$(./mvnw help:evaluate -Dexpression=project.artifactId -q -DforceStdout)-$(./mvnw help:evaluate -Dexpression=project.version -q -DforceStdout).jar target/app.jar
################################################################################
# Create a new stage for running the application that contains the minimal
# runtime dependencies for the application. This often uses a different base
# image from the install or build stage where the necessary files are copied
# from the install stage.
#
# The example below uses eclipse-temurin's JRE image as the foundation for running the app.
# By specifying the "8-jre-jammy" tag, it will also use whatever happens to be the
# most recent version of that tag when you build your Dockerfile.
# If reproducibility is important, consider using a specific digest SHA, like
# eclipse-temurin@sha256:99cede493dfd88720b610eb8077c8688d3cca50003d76d1d539b0efc8cca72b4.
FROM eclipse-temurin:8-jre-jammy AS final
# Create a non-privileged user that the app will run under.
# See https://docs.docker.com/go/dockerfile-user-best-practices/
ARG UID=10001
RUN adduser \
--disabled-password \
--gecos "" \
--home "/nonexistent" \
--shell "/sbin/nologin" \
--no-create-home \
--uid "${UID}" \
appuser
USER appuser
# Copy the executable from the "package" stage.
COPY --from=package build/target/app.jar app.jar
EXPOSE 8080
ENTRYPOINT [ "java", "-jar", "app.jar" ]

280
Readme.md Normal file
View File

@ -0,0 +1,280 @@
# 益普生医学信息支持系统 - 软件开发需求文档
## 1. 项目概述
### 1.1 项目目的
构建一个智能化的医学信息支持系统,解决企业在受到客户(主要是医生)的信息支持需求时,能够利用该网站快速处理信息,给到客户支持。
### 1.2 核心价值
### 1.2.1 提高医学信息查询效率
- 从信息申请的文本中智能化提取出用于后续检索的关键词
- 首先在已建立的知识库中进行查询,而后在查询外部公开网络中的信息
- 通过AI对查询的结果进行智能化分析
- 自动化文献下载和管理
### 1.2.2 标准化信息处理流程
- 规范化回复内容生成
## 2. 详细业务流程
### 2.1 咨询需求创建阶段
1. **客户需求提交**:客户通过邮件向益普生提出需求(如:请提供达菲林最新的临床试验数据)
2. **需求记录**:医学信息团队记录需求并指派专门人员处理
3. **系统录入**:医学信息专员将查询内容录入系统
- **必填字段**:查询内容描述
- **可选字段**:邮箱(非必填)、姓名、联系方式等(非必填)
### 2.2 智能关键词提取阶段
4. **AI关键词提取**系统通过AI从查询申请的具体描述中自动提取关键词
- 提取范围药品中文名称阿司匹林、英文名称Aspirin、查询请求事项儿童用药中的安全性注意事项
- 该提取动作通过调用Dify配置的AI流程来实现。Dify的API URL为https://api.dify.ai/v1 API Key为app-croZF0SSV5fiyXbK3neGrOT6
5. **关键词展示与确认**:在页面显示提取的关键词,供用户确认和修改
6. **关键词优化**:用户可添加、删除或修改关键词,形成检索条件,检查并确认检索条件的准确性
### 2.3 数据源选择阶段
7. **检索范围选择**:用户通过按钮触发选择需要在以下范围进行查找,可以多选:
- **内部数据**:企业自有研究数据、历史回复、内部文献
- **知识库**:已整理的企业知识库数据
- **知网**:中国知网数据库
- **ClinicalTrials**:临床试验数据库
### 2.4 信息检索与结果处理阶段
8. **信息检索**:根据选择的数据源和构建好的检索条件进行检索
9. **结果展示**:根据每一数据源分别在页面显示检索结果列表
10. **结果筛选**:用户对每条记录进行判断和处理:
- **错误信息**:标记为错误并删除
- **纳入回复参考资料**:选择是否纳入最终回复
- **下载全文**:选择是否需要下载全文
### 2.5 文献下载管理阶段
11. **下载任务生成**:对需要下载全文的文献生成下载链接
12. **待下载页面**:在专门的待下载全文页面显示所有下载任务
13. **自动化下载**后续开发自动化工具联合使用RPA工具影刀读取待下载任务并执行下载
### 2.6 智能分析与回复生成阶段
14. **资料整合**:将用户勾选的文件和资料进行整合
15. **AI智能分析**通过调用AI智能体阅读和分析所有选定资料AI智能体的API地址与key的信息将写到代码中
16. **回复生成**:形成与待回答问题相关的标准化回复,包含:
- **问题**:原始查询问题
- **查询到的资料**:相关资料的简要说明
- **总结**:基于资料的核心观点总结
- **详细的资料列表**:完整的参考资料清单
### 2.7 审核与发送阶段
17. **内容审核**:医学信息人员对生成的回复进行审核
18. **最终确认**:确认无误后发送给客户
### 2.8 处理状态显示
19. **任务状态**:查询任务的状态包括,
- 创建成功 (查询创建后则状态自动标记为创建成功)
- 关键词提取
- 检索范围确定
- 检索结果预览
- 生成回复
- 修改回复
- 下载文献
- 发送回复
### 2.9 状态机与流程编排(按钮驱动)
为保证流程可视、可控、可追踪,系统引入显式状态机。每个状态通过页面上的“主要动作按钮”触发进入下一状态;也支持必要的回退。
- **状态定义State**
- 创建成功 (CREATED)
- 关键词提取 (KEYWORD_EXTRACTION)
- 检索范围确定 (SCOPE_CONFIRMED)
- 检索结果预览 (RESULTS_REVIEW)
- 下载文献 (DOWNLOAD_PENDING)
- 生成回复 (DRAFT_GENERATION)
- 修改回复 (REVISION)
- 发送回复 (SENT)
- **核心转移Transitions及触发按钮Action**
- CREATED → KEYWORD_EXTRACTION按钮“开始提取关键词”
- KEYWORD_EXTRACTION → SCOPE_CONFIRMED按钮“确认关键词并选择数据源”
- SCOPE_CONFIRMED → RESULTS_REVIEW按钮“开始检索”
- RESULTS_REVIEW → DOWNLOAD_PENDING按钮“生成下载任务”当勾选需下载全文的文献
- RESULTS_REVIEW → DRAFT_GENERATION按钮“生成草稿回复”当已选定参考资料
- DOWNLOAD_PENDING → RESULTS_REVIEW按钮“刷新下载结果并回到预览”下载完成后复核
- DRAFT_GENERATION → REVISION按钮“进入编辑修改”
- REVISION → SENT按钮“发送最终回复”
- 允许回退:
- RESULTS_REVIEW → SCOPE_CONFIRMED按钮“返回调整检索范围”
- REVISION → RESULTS_REVIEW按钮“返回结果预览”
- **前端页面与按钮布局建议**
- 咨询详情页(`InquiryDetail.vue`)顶部固定“状态徽标 + 主动作按钮”区,底部提供上下文次要操作。
- 不同状态下展示不同的主要按钮,保持单一、明确的下一步。
- 当必需输入或选择未完成时,主要按钮置灰并给出就绪条件提示。
- **后端状态管理约束**
- 单一来源:后端持久化字段记录任务状态,前端仅展示并通过 API 触发转移。
- 转移校验:后端校验当前状态是否允许进入目标状态;对缺失前置条件(如未选资料却生成草稿)返回 4xx 并附带原因。
### 2.10 文本流程图Mermaid
可在支持 Mermaid 的查看器中直接渲染:
```mermaid
flowchart TD
CREATED[创建成功] -->|开始提取关键词| KEYWORD[关键词提取]
KEYWORD -->|确认关键词并选择数据源| SCOPE[检索范围确定]
SCOPE -->|开始检索| PREVIEW[检索结果预览]
PREVIEW -->|生成下载任务| DL[下载文献]
DL -->|刷新下载结果并回到预览| PREVIEW
PREVIEW -->|生成草稿回复| DRAFT[生成回复]
DRAFT -->|进入编辑修改| REV[修改回复]
REV -->|发送最终回复| SENT[发送回复]
PREVIEW -->|返回调整检索范围| SCOPE
REV -->|返回结果预览| PREVIEW
```
### 2.11 API 与前端动作映射(概要)
- **开始提取关键词**
- 前端:在 `InquiryDetail.vue` 调用 `POST /api/inquiry/{id}/keywords/extract`
- 成功:后端更新为 KEYWORD_EXTRACTION 或直接返回关键词,前端进入关键词确认 UI
- **确认关键词并选择数据源**
- 前端:`POST /api/inquiry/{id}/scope` 提交关键词与数据源清单
- 成功:后端更新为 SCOPE_CONFIRMED
- **开始检索**
- 前端:`POST /api/inquiry/{id}/search`(异步)
- 成功:后端更新为 RESULTS_REVIEW并可分页拉取结果 `GET /api/inquiry/{id}/results`
- **生成下载任务**
- 前端:`POST /api/inquiry/{id}/downloads` 提交选中的条目 ID
- 成功:后端更新为 DOWNLOAD_PENDING下载进度 `GET /api/download-tasks`
- **生成草稿回复**
- 前端:`POST /api/inquiry/{id}/response/draft` 提交选定资料引用
- 成功:后端更新为 DRAFT_GENERATION
- **进入编辑修改**
- 前端:进入富文本/结构化编辑 UI保存 `PUT /api/inquiry/{id}/response`
- 成功:后端更新为 REVISION
- **发送最终回复**
- 前端:`POST /api/inquiry/{id}/send`
- 成功:后端更新为 SENT记录审计日志
## 3. 功能模块详细设计
### 3.1 用户管理模块
- **用户注册/登录**:支持多种角色(医学信息专员、管理员等)
- **权限管理**:基于角色的访问控制
- **用户信息管理**:个人资料维护
### 3.2 咨询需求管理模块
- **需求创建**:支持手动填写查询需求
- **需求列表**:查看所有咨询需求
- **需求详情**:查看具体需求信息
- **需求状态管理**:跟踪需求处理状态
### 3.3 智能关键词提取模块
- **AI关键词提取**基于Dify API的智能提取
- **关键词编辑**:用户可修改、添加、删除关键词
- **关键词历史**:保存历史提取记录
- **关键词模板**:常用关键词模板管理
### 3.4 数据源管理模块
- **数据源配置**:配置各种数据源的连接信息
- **检索策略**:定义检索顺序和策略
- **数据源状态监控**:监控各数据源可用性
### 3.5 信息检索模块
- **多源检索**支持内部数据、知识库、知网、ClinicalTrials等
- **检索结果管理**:结果展示、筛选、排序
- **检索历史**:保存检索记录和结果
### 3.6 文献下载管理模块
- **下载任务管理**:创建、查看、管理下载任务
- **下载状态跟踪**:实时跟踪下载进度
- **文件存储管理**:管理下载的文件
- **下载配置**:配置下载账号和路径
### 3.7 AI智能分析模块
- **文档阅读**AI智能体阅读选定资料
- **内容分析**:提取关键信息和观点
- **回复生成**:生成标准化回复内容
- **格式规范**:确保回复格式符合要求
### 3.8 知识库管理模块
- **知识库配置**:管理各类知识库连接
- **内容管理**:添加、编辑、删除知识库内容
- **分类管理**:知识库内容分类和标签
- **搜索功能**:知识库内容搜索
### 3.9 系统配置模块
- **API配置**配置Dify和其他API密钥DIFY_API_URL=https://api.dify.ai/v1
DIFY_API_KEY=your-dify-api-key-here
- **系统参数**:配置系统运行参数
- **数据源配置**:配置各种数据源连接
- **下载配置**:配置文献下载相关参数
## 4. 技术架构要求
### 4.1 开发技术栈
- **后端**Java + Spring Boot
- **前端**Vue3
- **数据库**MySQL 8.0+
- **AI引擎**Dify API
### 4.2 系统架构
- **架构模式**:前后端分离
- **API设计**RESTful API
- **安全认证**JWT Token
- **文件存储**:本地存储
### 4.3 部署要求
- **容器化**支持Docker部署
- **环境配置**:支持多环境配置
- **监控日志**:集成日志和监控系统
## 5. 数据源配置要求
### 5.1 内部数据源
- **企业研究数据**:内部研究项目数据
- **历史回复数据**:过往咨询回复记录
- **内部文献**:企业内部技术文档和报告
### 5.2 公开数据源
- **监管机构网站**FDA、EMA等监管数据
- **知网**:中国知网学术文献
- **PubMed**:医学文献数据库
- **EMBASE**:生物医学文献数据库
- **ClinicalTrials.gov**:临床试验数据库
### 5.3 扩展数据源
- **疾病数据**:疾病相关信息数据库
- **药物数据**:药物信息数据库
- **关联数据**:疾病-药物关联数据
## 6. 界面设计要求
### 6.1 整体设计原则
- **用户友好**:界面简洁直观,操作便捷
- **响应式设计**:支持多种屏幕尺寸
- **一致性**:保持界面风格统一
### 6.2 关键页面设计
- **咨询需求创建页面**:简化表单,突出必填项
- **关键词确认页面**:清晰展示提取结果,支持编辑
- **数据源选择页面**:直观的复选框选择界面
- **检索结果页面**:列表展示,支持批量操作
- **待下载页面**:任务列表,支持批量下载
- **回复生成页面**:分步骤展示生成过程
## 7. 非功能性需求
### 7.1 性能要求
- **响应时间**:页面加载时间 < 3秒
- **并发处理**:支持多用户同时操作
- **数据处理**:支持大量文献数据处理
### 7.2 安全要求
- **数据安全**:敏感数据加密存储
- **访问控制**:基于角色的权限管理
- **审计日志**:记录所有操作日志
### 7.3 可维护性
- **代码规范**遵循Java和Vue开发规范
- **文档完整**提供完整的API和用户文档
- **模块化设计**:便于功能扩展和维护

View File

@ -1,31 +0,0 @@
# 使用Maven构建
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
# 复制pom.xml并下载依赖利用Docker缓存
COPY pom.xml .
RUN mvn dependency:go-offline -B
# 复制源代码并构建
COPY src ./src
RUN mvn clean package -DskipTests
# 运行阶段
FROM eclipse-temurin:17-jre
WORKDIR /app
# 复制构建好的jar包
COPY --from=build /app/target/*.jar app.jar
# 创建必要的目录
RUN mkdir -p /app/downloads /app/logs
# 暴露端口
EXPOSE 8080
# 运行应用
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE:-prod}", "app.jar"]

View File

@ -1,6 +0,0 @@
{
"name": "backend",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@ -1,129 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<groupId>com.ipsen</groupId>
<artifactId>medical-info-system</artifactId>
<version>1.0.0</version>
<name>Medical Information Support System</name>
<description>Medical Information Support System for Ipsen</description>
<properties>
<java.version>8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Spring Boot Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Spring Boot Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- MySQL Driver -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- Apache POI for Excel -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.5</version>
</dependency>
<!-- HTTP Client for Dify API -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -1,130 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<groupId>com.ipsen</groupId>
<artifactId>medical-info-system</artifactId>
<version>1.0.0</version>
<name>Medical Information Support System</name>
<description>Medical Information Support System for Ipsen</description>
<properties>
<java.version>8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Spring Boot Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Spring Boot Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- MySQL Driver -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- Apache POI for Excel -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.5</version>
</dependency>
<!-- HTTP Client for Dify API -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,75 @@
package com.ipsen.medical.controller;
import com.ipsen.medical.dto.ApiResponse;
import com.ipsen.medical.dto.SearchResultItemDTO;
import com.ipsen.medical.service.SearchResultService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 下载任务控制器
* 用于管理文献和资料的下载任务
*/
@RestController
@RequestMapping("/download-tasks")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class DownloadTaskController {
private final SearchResultService searchResultService;
/**
* 获取所有待下载的任务
*/
@GetMapping("/pending")
public ResponseEntity<ApiResponse<List<SearchResultItemDTO>>> getAllPendingDownloads() {
List<SearchResultItemDTO> tasks = searchResultService.getPendingDownloads();
return ResponseEntity.ok(ApiResponse.success(tasks));
}
/**
* 获取特定查询请求的待下载任务
*/
@GetMapping("/inquiry/{inquiryId}/pending")
public ResponseEntity<ApiResponse<List<SearchResultItemDTO>>> getPendingDownloadsByInquiry(
@PathVariable Long inquiryId) {
List<SearchResultItemDTO> tasks = searchResultService.getPendingDownloadsByInquiry(inquiryId);
return ResponseEntity.ok(ApiResponse.success(tasks));
}
/**
* 标记下载任务为完成
*/
@PostMapping("/{id}/complete")
public ResponseEntity<ApiResponse<SearchResultItemDTO>> markDownloadComplete(
@PathVariable Long id,
@RequestParam String filePath) {
SearchResultItemDTO dto = new SearchResultItemDTO();
dto.setId(id);
dto.setDownloadStatus("COMPLETED");
dto.setFilePath(filePath);
SearchResultItemDTO result = searchResultService.updateSearchResult(id, dto);
return ResponseEntity.ok(ApiResponse.success(result));
}
/**
* 标记下载任务为失败
*/
@PostMapping("/{id}/fail")
public ResponseEntity<ApiResponse<SearchResultItemDTO>> markDownloadFailed(
@PathVariable Long id,
@RequestParam(required = false) String reason) {
SearchResultItemDTO dto = new SearchResultItemDTO();
dto.setId(id);
dto.setDownloadStatus("FAILED");
SearchResultItemDTO result = searchResultService.updateSearchResult(id, dto);
return ResponseEntity.ok(ApiResponse.success(result));
}
}

View File

@ -2,7 +2,9 @@ package com.ipsen.medical.controller;
import com.ipsen.medical.dto.ApiResponse;
import com.ipsen.medical.dto.ClinicalTrialDTO;
import com.ipsen.medical.dto.DataSourceSelectionDTO;
import com.ipsen.medical.dto.InquiryRequestDTO;
import com.ipsen.medical.dto.KeywordConfirmationDTO;
import com.ipsen.medical.service.AutoSearchService;
import com.ipsen.medical.service.ClinicalTrialsService;
import com.ipsen.medical.service.InquiryService;
@ -76,6 +78,28 @@ public class InquiryController {
return ResponseEntity.ok(ApiResponse.success(result));
}
/**
* 确认和更新关键词
*/
@PostMapping("/{id}/confirm-keywords")
public ResponseEntity<ApiResponse<InquiryRequestDTO>> confirmKeywords(
@PathVariable Long id,
@RequestBody KeywordConfirmationDTO keywordDTO) {
InquiryRequestDTO result = inquiryService.confirmKeywords(id, keywordDTO);
return ResponseEntity.ok(ApiResponse.success(result));
}
/**
* 选择数据源
*/
@PostMapping("/{id}/select-data-sources")
public ResponseEntity<ApiResponse<InquiryRequestDTO>> selectDataSources(
@PathVariable Long id,
@RequestBody DataSourceSelectionDTO dataSourceDTO) {
InquiryRequestDTO result = inquiryService.selectDataSources(id, dataSourceDTO);
return ResponseEntity.ok(ApiResponse.success(result));
}
/**
* 执行信息检索
*/

View File

@ -0,0 +1,105 @@
package com.ipsen.medical.controller;
import com.ipsen.medical.dto.ApiResponse;
import com.ipsen.medical.dto.SearchResultItemDTO;
import com.ipsen.medical.service.SearchResultService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 检索结果控制器
*/
@RestController
@RequestMapping("/search-results")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class SearchResultController {
private final SearchResultService searchResultService;
/**
* 获取查询请求的所有检索结果
*/
@GetMapping("/inquiry/{inquiryId}")
public ResponseEntity<ApiResponse<List<SearchResultItemDTO>>> getSearchResults(
@PathVariable Long inquiryId) {
List<SearchResultItemDTO> results = searchResultService.getActiveSearchResults(inquiryId);
return ResponseEntity.ok(ApiResponse.success(results));
}
/**
* 更新检索结果项
*/
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<SearchResultItemDTO>> updateSearchResult(
@PathVariable Long id,
@RequestBody SearchResultItemDTO dto) {
SearchResultItemDTO result = searchResultService.updateSearchResult(id, dto);
return ResponseEntity.ok(ApiResponse.success(result));
}
/**
* 批量更新检索结果项
*/
@PutMapping("/batch")
public ResponseEntity<ApiResponse<List<SearchResultItemDTO>>> batchUpdateSearchResults(
@RequestBody List<SearchResultItemDTO> dtos) {
List<SearchResultItemDTO> results = searchResultService.batchUpdateSearchResults(dtos);
return ResponseEntity.ok(ApiResponse.success(results));
}
/**
* 标记为错误并删除
*/
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> markAsDeleted(@PathVariable Long id) {
searchResultService.markAsDeleted(id);
return ResponseEntity.ok(ApiResponse.success(null));
}
/**
* 设置是否纳入回复参考资料
*/
@PostMapping("/{id}/include")
public ResponseEntity<ApiResponse<SearchResultItemDTO>> setIncludeInResponse(
@PathVariable Long id,
@RequestParam Boolean include) {
SearchResultItemDTO result = searchResultService.setIncludeInResponse(id, include);
return ResponseEntity.ok(ApiResponse.success(result));
}
/**
* 设置是否需要下载全文
*/
@PostMapping("/{id}/download")
public ResponseEntity<ApiResponse<SearchResultItemDTO>> setNeedDownload(
@PathVariable Long id,
@RequestParam Boolean need) {
SearchResultItemDTO result = searchResultService.setNeedDownload(id, need);
return ResponseEntity.ok(ApiResponse.success(result));
}
/**
* 获取所有待下载的结果项
*/
@GetMapping("/pending-downloads")
public ResponseEntity<ApiResponse<List<SearchResultItemDTO>>> getPendingDownloads() {
List<SearchResultItemDTO> results = searchResultService.getPendingDownloads();
return ResponseEntity.ok(ApiResponse.success(results));
}
/**
* 获取查询请求的待下载结果项
*/
@GetMapping("/inquiry/{inquiryId}/pending-downloads")
public ResponseEntity<ApiResponse<List<SearchResultItemDTO>>> getPendingDownloadsByInquiry(
@PathVariable Long inquiryId) {
List<SearchResultItemDTO> results = searchResultService.getPendingDownloadsByInquiry(inquiryId);
return ResponseEntity.ok(ApiResponse.success(results));
}
}

View File

@ -1,10 +1,11 @@
package com.ipsen.medical.controller;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.ipsen.medical.dto.ApiResponse;
import com.ipsen.medical.service.DifyService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
@ -14,8 +15,11 @@ import java.util.Map;
@RestController
@RequestMapping("/test")
@CrossOrigin(origins = "*")
@RequiredArgsConstructor
public class TestController {
private final DifyService difyService;
@GetMapping("/health")
public Map<String, Object> health() {
Map<String, Object> response = new HashMap<>();
@ -34,4 +38,43 @@ public class TestController {
response.put("java.home", System.getProperty("java.home"));
return response;
}
@GetMapping("/dify-connection")
public ApiResponse<String> testDifyConnection() {
try {
String result = difyService.extractKeywords("测试连接");
return ApiResponse.success("Dify API连接成功: " + result);
} catch (Exception e) {
return ApiResponse.error("Dify API连接失败: " + e.getMessage());
}
}
@GetMapping("/dify-config")
public ApiResponse<Map<String, Object>> checkDifyConfig() {
Map<String, Object> config = new HashMap<>();
// 通过反射获取配置值
try {
Field apiUrlField = difyService.getClass().getDeclaredField("difyApiUrl");
Field apiKeyField = difyService.getClass().getDeclaredField("difyApiKey");
apiUrlField.setAccessible(true);
apiKeyField.setAccessible(true);
String apiUrl = (String) apiUrlField.get(difyService);
String apiKey = (String) apiKeyField.get(difyService);
config.put("apiUrl", apiUrl);
config.put("apiKeyConfigured", apiKey != null && !apiKey.equals("your-dify-api-key-here") && !apiKey.trim().isEmpty());
config.put("apiKeyPreview", apiKey != null ? apiKey.substring(0, Math.min(10, apiKey.length())) + "..." : "null");
boolean apiKeyConfigured = (Boolean) config.get("apiKeyConfigured");
config.put("status", apiKeyConfigured ? "OK" : "NEEDS_CONFIGURATION");
} catch (Exception e) {
config.put("error", "无法获取配置信息: " + e.getMessage());
config.put("status", "ERROR");
}
return ApiResponse.success(config);
}
}

View File

@ -48,3 +48,5 @@ public class ClinicalTrialDTO {

View File

@ -27,3 +27,5 @@ public class ClinicalTrialsSearchResult {

View File

@ -0,0 +1,16 @@
package com.ipsen.medical.dto;
import lombok.Data;
/**
* 数据源选择DTO
*/
@Data
public class DataSourceSelectionDTO {
private Boolean searchInternalData; // 是否在内部数据中检索
private Boolean searchKnowledgeBase; // 是否在知识库中检索
private Boolean searchCnki; // 是否在知网中检索
private Boolean searchClinicalTrials; // 是否在ClinicalTrials中检索
}

View File

@ -12,6 +12,11 @@ public class InquiryRequestDTO {
private String customerTitle;
private String inquiryContent;
private String keywords;
private Boolean keywordsConfirmed;
private Boolean searchInternalData;
private Boolean searchKnowledgeBase;
private Boolean searchCnki;
private Boolean searchClinicalTrials;
private String status;
private String searchResults;
private String responseContent;

View File

@ -0,0 +1,17 @@
package com.ipsen.medical.dto;
import lombok.Data;
import java.util.List;
/**
* 关键词确认DTO
*/
@Data
public class KeywordConfirmationDTO {
private String drugNameChinese;
private String drugNameEnglish;
private String requestItem;
private List<String> additionalKeywords; // 用户添加的其他关键词
}

View File

@ -0,0 +1,32 @@
package com.ipsen.medical.dto;
import lombok.Data;
import java.util.List;
/**
* 回复生成DTO
* 包含标准化的回复格式
*/
@Data
public class ResponseGenerationDTO {
private String question; // 原始查询问题
private String queriedMaterials; // 查询到的资料简要说明
private String summary; // 基于资料的核心观点总结
private List<MaterialReference> materialList; // 详细的资料列表
/**
* 资料引用
*/
@Data
public static class MaterialReference {
private String title; // 标题
private String authors; // 作者
private String source; // 来源
private String publicationDate; // 发表日期
private String url; // 链接
private String summary; // 摘要
private String relevance; // 相关性说明
}
}

View File

@ -0,0 +1,35 @@
package com.ipsen.medical.dto;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 检索结果项DTO
*/
@Data
public class SearchResultItemDTO {
private Long id;
private Long inquiryRequestId;
private String title;
private String summary;
private String content;
private String authors;
private String source;
private String sourceUrl;
private String publicationDate;
private String doi;
private String pmid;
private String nctId;
private String metadata;
private String status;
private Boolean includeInResponse;
private Boolean needDownload;
private Boolean isDeleted;
private String filePath;
private String downloadStatus;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime downloadedAt;
}

View File

@ -0,0 +1,22 @@
package com.ipsen.medical.dto;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 用户DTO
*/
@Data
public class UserDTO {
private Long id;
private String username;
private String password; // 仅用于创建/更新不返回
private String fullName;
private String email;
private String role;
private Boolean enabled;
private LocalDateTime createdAt;
private LocalDateTime lastLoginAt;
}

View File

@ -19,19 +19,28 @@ public class InquiryRequest {
@Column(nullable = false)
private String requestNumber; // 请求编号
@Column(nullable = false)
private String customerName; // 客户姓名
private String customerName; // 客户姓名非必填
private String customerEmail; // 客户邮箱
private String customerEmail; // 客户邮箱非必填
private String customerTitle; // 客户职称
@Column(columnDefinition = "TEXT")
private String inquiryContent; // 查询内容
@Column(columnDefinition = "TEXT", nullable = false)
private String inquiryContent; // 查询内容必填
@Column(columnDefinition = "TEXT")
private String keywords; // 提取的关键词JSON格式
private Boolean keywordsConfirmed; // 用户是否确认关键词
private Boolean searchInternalData; // 是否在内部数据中检索
private Boolean searchKnowledgeBase; // 是否在知识库中检索
private Boolean searchCnki; // 是否在知网中检索
private Boolean searchClinicalTrials; // 是否在ClinicalTrials中检索
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private RequestStatus status; // 状态

View File

@ -0,0 +1,113 @@
package com.ipsen.medical.entity;
import javax.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 检索结果项实体
* 用于存储单条检索结果支持用户对每条结果进行标记和处理
*/
@Data
@Entity
@Table(name = "search_result_items")
public class SearchResultItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "inquiry_request_id", nullable = false)
private InquiryRequest inquiryRequest;
@Column(nullable = false)
private String title; // 标题
@Column(columnDefinition = "TEXT")
private String summary; // 摘要
@Column(columnDefinition = "TEXT")
private String content; // 内容
private String authors; // 作者
private String source; // 来源知网ClinicalTrials知识库等
private String sourceUrl; // 来源URL
private String publicationDate; // 发表日期
private String doi; // DOI
private String pmid; // PubMed ID
private String nctId; // ClinicalTrials NCT ID
@Column(columnDefinition = "TEXT")
private String metadata; // 其他元数据JSON格式
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ResultStatus status; // 结果状态
private Boolean includeInResponse; // 是否纳入回复参考资料
private Boolean needDownload; // 是否需要下载全文
private Boolean isDeleted; // 是否标记为错误并删除
private String filePath; // 下载后的文件路径
@Enumerated(EnumType.STRING)
private DownloadStatus downloadStatus; // 下载状态
@Column(nullable = false)
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime downloadedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
if (includeInResponse == null) {
includeInResponse = false;
}
if (needDownload == null) {
needDownload = false;
}
if (isDeleted == null) {
isDeleted = false;
}
if (status == null) {
status = ResultStatus.PENDING_REVIEW;
}
if (downloadStatus == null) {
downloadStatus = DownloadStatus.NOT_REQUIRED;
}
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
public enum ResultStatus {
PENDING_REVIEW, // 待审核
APPROVED, // 已批准
REJECTED, // 已拒绝
DELETED // 已删除错误信息
}
public enum DownloadStatus {
NOT_REQUIRED, // 不需要下载
PENDING, // 待下载
DOWNLOADING, // 下载中
COMPLETED, // 已完成
FAILED // 失败
}
}

View File

@ -37,3 +37,5 @@ public interface ClinicalTrialRepository extends JpaRepository<ClinicalTrial, Lo

View File

@ -0,0 +1,46 @@
package com.ipsen.medical.repository;
import com.ipsen.medical.entity.SearchResultItem;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* 检索结果项存储库
*/
@Repository
public interface SearchResultItemRepository extends JpaRepository<SearchResultItem, Long> {
/**
* 根据查询请求ID查找所有结果项
*/
List<SearchResultItem> findByInquiryRequestId(Long inquiryRequestId);
/**
* 根据查询请求ID查找需要纳入回复的结果项
*/
List<SearchResultItem> findByInquiryRequestIdAndIncludeInResponseTrue(Long inquiryRequestId);
/**
* 根据查询请求ID查找需要下载的结果项
*/
List<SearchResultItem> findByInquiryRequestIdAndNeedDownloadTrue(Long inquiryRequestId);
/**
* 根据查询请求ID查找未删除的结果项
*/
List<SearchResultItem> findByInquiryRequestIdAndIsDeletedFalse(Long inquiryRequestId);
/**
* 根据查询请求ID删除所有结果项
*/
void deleteByInquiryRequestId(Long inquiryRequestId);
/**
* 查找所有待下载的结果项
*/
List<SearchResultItem> findByDownloadStatus(SearchResultItem.DownloadStatus status);
}

View File

@ -50,3 +50,5 @@ public interface ClinicalTrialsService {

View File

@ -1,6 +1,7 @@
package com.ipsen.medical.service;
import com.ipsen.medical.dto.KeywordExtractionResult;
import com.ipsen.medical.dto.ResponseGenerationDTO;
/**
* Dify AI服务接口
@ -23,9 +24,14 @@ public interface DifyService {
String performSearch(String keywords);
/**
* 生成回复
* 生成回复返回JSON字符串
*/
String generateResponse(String inquiryContent, String searchResults);
/**
* 生成结构化回复
*/
ResponseGenerationDTO generateStructuredResponse(String inquiryContent, String selectedMaterials);
}

View File

@ -1,6 +1,8 @@
package com.ipsen.medical.service;
import com.ipsen.medical.dto.DataSourceSelectionDTO;
import com.ipsen.medical.dto.InquiryRequestDTO;
import com.ipsen.medical.dto.KeywordConfirmationDTO;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@ -36,7 +38,17 @@ public interface InquiryService {
InquiryRequestDTO extractKeywords(Long id);
/**
* 执行信息检索
* 确认和更新关键词
*/
InquiryRequestDTO confirmKeywords(Long id, KeywordConfirmationDTO keywordDTO);
/**
* 选择数据源
*/
InquiryRequestDTO selectDataSources(Long id, DataSourceSelectionDTO dataSourceDTO);
/**
* 执行信息检索基于选择的数据源
*/
InquiryRequestDTO performSearch(Long id);

View File

@ -0,0 +1,41 @@
package com.ipsen.medical.service;
import com.ipsen.medical.dto.SearchResultItemDTO;
import java.util.List;
/**
* 多源检索服务接口
* 支持内部数据知识库知网ClinicalTrials等多个数据源的检索
*/
public interface MultiSourceSearchService {
/**
* 根据查询请求执行多源检索
* @param inquiryId 查询请求ID
* @return 检索结果列表
*/
List<SearchResultItemDTO> performMultiSourceSearch(Long inquiryId);
/**
* 在内部数据中检索
*/
List<SearchResultItemDTO> searchInternalData(Long inquiryId, String keywords);
/**
* 在知识库中检索
*/
List<SearchResultItemDTO> searchKnowledgeBase(Long inquiryId, String keywords);
/**
* 在知网中检索
*/
List<SearchResultItemDTO> searchCnki(Long inquiryId, String keywords);
/**
* 在ClinicalTrials中检索
*/
List<SearchResultItemDTO> searchClinicalTrials(Long inquiryId, String keywords);
}

View File

@ -0,0 +1,68 @@
package com.ipsen.medical.service;
import com.ipsen.medical.dto.SearchResultItemDTO;
import java.util.List;
/**
* 检索结果服务接口
*/
public interface SearchResultService {
/**
* 获取查询请求的所有检索结果
*/
List<SearchResultItemDTO> getSearchResults(Long inquiryRequestId);
/**
* 获取查询请求的未删除检索结果
*/
List<SearchResultItemDTO> getActiveSearchResults(Long inquiryRequestId);
/**
* 更新检索结果项
*/
SearchResultItemDTO updateSearchResult(Long id, SearchResultItemDTO dto);
/**
* 批量更新检索结果项
*/
List<SearchResultItemDTO> batchUpdateSearchResults(List<SearchResultItemDTO> dtos);
/**
* 标记为错误并删除
*/
void markAsDeleted(Long id);
/**
* 设置是否纳入回复参考资料
*/
SearchResultItemDTO setIncludeInResponse(Long id, Boolean include);
/**
* 设置是否需要下载全文
*/
SearchResultItemDTO setNeedDownload(Long id, Boolean need);
/**
* 获取所有待下载的结果项
*/
List<SearchResultItemDTO> getPendingDownloads();
/**
* 获取查询请求的待下载结果项
*/
List<SearchResultItemDTO> getPendingDownloadsByInquiry(Long inquiryRequestId);
/**
* 创建检索结果项
*/
SearchResultItemDTO createSearchResult(SearchResultItemDTO dto);
/**
* 批量创建检索结果项
*/
List<SearchResultItemDTO> batchCreateSearchResults(List<SearchResultItemDTO> dtos);
}

View File

@ -0,0 +1,48 @@
package com.ipsen.medical.service;
import com.ipsen.medical.dto.UserDTO;
import java.util.List;
/**
* 用户服务接口
*/
public interface UserService {
/**
* 创建用户
*/
UserDTO createUser(UserDTO userDTO);
/**
* 获取用户
*/
UserDTO getUser(Long id);
/**
* 根据用户名获取用户
*/
UserDTO getUserByUsername(String username);
/**
* 获取所有用户
*/
List<UserDTO> getAllUsers();
/**
* 更新用户
*/
UserDTO updateUser(Long id, UserDTO userDTO);
/**
* 删除用户
*/
void deleteUser(Long id);
/**
* 启用/禁用用户
*/
UserDTO toggleUserStatus(Long id, Boolean enabled);
}

View File

@ -3,8 +3,8 @@ package com.ipsen.medical.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ipsen.medical.dto.DifyRequest;
import com.ipsen.medical.dto.DifyResponse;
import com.ipsen.medical.dto.KeywordExtractionResult;
import com.ipsen.medical.dto.ResponseGenerationDTO;
import com.ipsen.medical.service.DifyService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
@ -13,6 +13,7 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.Map;
@ -45,6 +46,13 @@ public class DifyServiceImpl implements DifyService {
public KeywordExtractionResult extractKeywordsStructured(String content) {
log.info("Extracting keywords structured from content");
// 检查API Key配置
if (difyApiKey == null || difyApiKey.equals("your-dify-api-key-here") || difyApiKey.trim().isEmpty()) {
log.error("Dify API Key not configured properly. Current value: {}",
difyApiKey != null ? difyApiKey.substring(0, Math.min(20, difyApiKey.length())) + "..." : "null");
throw new RuntimeException("Dify API Key未配置。请设置环境变量DIFY_API_KEY或修改application.yml中的配置。");
}
try {
String response = callDifyAPI(content, "extract_keywords");
@ -132,12 +140,66 @@ public class DifyServiceImpl implements DifyService {
}
}
@Override
public ResponseGenerationDTO generateStructuredResponse(String inquiryContent, String selectedMaterials) {
log.info("Generating structured response");
try {
String prompt = String.format(
"请基于以下查询问题和检索到的资料生成标准化回复。\n\n" +
"查询问题:%s\n\n" +
"检索到的资料:%s\n\n" +
"请按照以下JSON格式返回\n" +
"{\n" +
" \"question\": \"原始查询问题\",\n" +
" \"queriedMaterials\": \"查询到的资料简要说明\",\n" +
" \"summary\": \"基于资料的核心观点总结\",\n" +
" \"materialList\": [\n" +
" {\n" +
" \"title\": \"标题\",\n" +
" \"authors\": \"作者\",\n" +
" \"source\": \"来源\",\n" +
" \"publicationDate\": \"发表日期\",\n" +
" \"url\": \"链接\",\n" +
" \"summary\": \"摘要\",\n" +
" \"relevance\": \"相关性说明\"\n" +
" }\n" +
" ]\n" +
"}",
inquiryContent, selectedMaterials
);
String response = callDifyAPI(prompt, "generate_structured_response");
// 解析响应
JsonNode rootNode = objectMapper.readTree(response);
JsonNode answerNode = rootNode.path("answer");
String answer;
if (answerNode.isTextual()) {
answer = answerNode.asText();
} else {
answer = answerNode.toString();
}
// 解析结构化回复
ResponseGenerationDTO result = objectMapper.readValue(answer, ResponseGenerationDTO.class);
return result;
} catch (Exception e) {
log.error("Error generating structured response: ", e);
throw new RuntimeException("Failed to generate structured response: " + e.getMessage(), e);
}
}
/**
* 调用Dify API
*/
private String callDifyAPI(String content, String taskType) {
try {
log.info("Calling Dify API: url={}, taskType={}", difyApiUrl, taskType);
log.info("API Key: {}", difyApiKey != null ? difyApiKey.substring(0, Math.min(10, difyApiKey.length())) + "..." : "null");
// 构建请求
DifyRequest request = new DifyRequest();
@ -151,6 +213,8 @@ public class DifyServiceImpl implements DifyService {
inputs.put("content", content);
request.setInputs(inputs);
log.debug("Dify API request: {}", objectMapper.writeValueAsString(request));
// 调用API
String response = webClient.post()
.uri(difyApiUrl + "/chat-messages")
@ -158,6 +222,16 @@ public class DifyServiceImpl implements DifyService {
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.bodyValue(request)
.retrieve()
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
clientResponse -> {
log.error("Dify API error: status={}, headers={}",
clientResponse.statusCode(), clientResponse.headers().asHttpHeaders());
return clientResponse.bodyToMono(String.class)
.flatMap(body -> {
log.error("Dify API error body: {}", body);
return Mono.error(new RuntimeException("Dify API error: " + clientResponse.statusCode() + " - " + body));
});
})
.bodyToMono(String.class)
.block();

View File

@ -1,10 +1,13 @@
package com.ipsen.medical.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ipsen.medical.dto.DataSourceSelectionDTO;
import com.ipsen.medical.dto.InquiryRequestDTO;
import com.ipsen.medical.dto.KeywordConfirmationDTO;
import com.ipsen.medical.dto.ResponseGenerationDTO;
import com.ipsen.medical.dto.SearchResultItemDTO;
import com.ipsen.medical.entity.InquiryRequest;
import com.ipsen.medical.entity.User;
import com.ipsen.medical.repository.InquiryRequestRepository;
import com.ipsen.medical.repository.UserRepository;
import com.ipsen.medical.service.InquiryService;
import com.ipsen.medical.service.ExcelParserService;
import com.ipsen.medical.service.DifyService;
@ -27,15 +30,20 @@ public class InquiryServiceImpl implements InquiryService {
@Autowired
private InquiryRequestRepository inquiryRequestRepository;
@Autowired
private UserRepository userRepository;
@Autowired
private ExcelParserService excelParserService;
@Autowired
private DifyService difyService;
@Autowired
private com.ipsen.medical.service.MultiSourceSearchService multiSourceSearchService;
@Autowired
private com.ipsen.medical.service.SearchResultService searchResultService;
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public InquiryRequestDTO uploadInquiry(MultipartFile file) {
try {
@ -115,14 +123,14 @@ public class InquiryServiceImpl implements InquiryService {
.orElseThrow(() -> new RuntimeException("查询请求不存在: " + id));
try {
// 使用Dify服务执行信息检索
String searchResults = difyService.performSearch(inquiryRequest.getKeywords());
inquiryRequest.setSearchResults(searchResults);
inquiryRequest.setStatus(InquiryRequest.RequestStatus.SEARCH_COMPLETED);
inquiryRequest.setUpdatedAt(LocalDateTime.now());
// 执行多源检索
multiSourceSearchService.performMultiSourceSearch(id);
InquiryRequest savedRequest = inquiryRequestRepository.save(inquiryRequest);
return convertToDTO(savedRequest);
// 重新加载更新后的请求
inquiryRequest = inquiryRequestRepository.findById(id)
.orElseThrow(() -> new RuntimeException("查询请求不存在: " + id));
return convertToDTO(inquiryRequest);
} catch (Exception e) {
throw new RuntimeException("信息检索失败: " + e.getMessage(), e);
}
@ -134,11 +142,27 @@ public class InquiryServiceImpl implements InquiryService {
.orElseThrow(() -> new RuntimeException("查询请求不存在: " + id));
try {
// 使用Dify服务生成回复内容
String responseContent = difyService.generateResponse(
// 获取用户选中的检索结果includeInResponse = true
List<SearchResultItemDTO> selectedResults = searchResultService.getSearchResults(id).stream()
.filter(r -> Boolean.TRUE.equals(r.getIncludeInResponse()))
.collect(Collectors.toList());
if (selectedResults.isEmpty()) {
throw new RuntimeException("未选择任何参考资料,无法生成回复");
}
// 将选中的资料转换为JSON字符串
String selectedMaterials = objectMapper.writeValueAsString(selectedResults);
// 使用Dify服务生成结构化回复
ResponseGenerationDTO structuredResponse = difyService.generateStructuredResponse(
inquiryRequest.getInquiryContent(),
inquiryRequest.getSearchResults()
selectedMaterials
);
// 将结构化回复转换为JSON字符串保存
String responseContent = objectMapper.writeValueAsString(structuredResponse);
inquiryRequest.setResponseContent(responseContent);
inquiryRequest.setStatus(InquiryRequest.RequestStatus.UNDER_REVIEW);
inquiryRequest.setUpdatedAt(LocalDateTime.now());
@ -180,6 +204,41 @@ public class InquiryServiceImpl implements InquiryService {
return convertToDTO(savedRequest);
}
@Override
public InquiryRequestDTO confirmKeywords(Long id, KeywordConfirmationDTO keywordDTO) {
InquiryRequest inquiryRequest = inquiryRequestRepository.findById(id)
.orElseThrow(() -> new RuntimeException("查询请求不存在: " + id));
try {
// 将用户确认的关键词保存为JSON
String keywordsJson = objectMapper.writeValueAsString(keywordDTO);
inquiryRequest.setKeywords(keywordsJson);
inquiryRequest.setKeywordsConfirmed(true);
inquiryRequest.setUpdatedAt(LocalDateTime.now());
InquiryRequest savedRequest = inquiryRequestRepository.save(inquiryRequest);
return convertToDTO(savedRequest);
} catch (Exception e) {
throw new RuntimeException("关键词确认失败: " + e.getMessage(), e);
}
}
@Override
public InquiryRequestDTO selectDataSources(Long id, DataSourceSelectionDTO dataSourceDTO) {
InquiryRequest inquiryRequest = inquiryRequestRepository.findById(id)
.orElseThrow(() -> new RuntimeException("查询请求不存在: " + id));
// 保存用户选择的数据源
inquiryRequest.setSearchInternalData(dataSourceDTO.getSearchInternalData());
inquiryRequest.setSearchKnowledgeBase(dataSourceDTO.getSearchKnowledgeBase());
inquiryRequest.setSearchCnki(dataSourceDTO.getSearchCnki());
inquiryRequest.setSearchClinicalTrials(dataSourceDTO.getSearchClinicalTrials());
inquiryRequest.setUpdatedAt(LocalDateTime.now());
InquiryRequest savedRequest = inquiryRequestRepository.save(inquiryRequest);
return convertToDTO(savedRequest);
}
/**
* 生成请求编号
*/
@ -199,6 +258,11 @@ public class InquiryServiceImpl implements InquiryService {
dto.setCustomerTitle(inquiryRequest.getCustomerTitle());
dto.setInquiryContent(inquiryRequest.getInquiryContent());
dto.setKeywords(inquiryRequest.getKeywords());
dto.setKeywordsConfirmed(inquiryRequest.getKeywordsConfirmed());
dto.setSearchInternalData(inquiryRequest.getSearchInternalData());
dto.setSearchKnowledgeBase(inquiryRequest.getSearchKnowledgeBase());
dto.setSearchCnki(inquiryRequest.getSearchCnki());
dto.setSearchClinicalTrials(inquiryRequest.getSearchClinicalTrials());
dto.setStatus(inquiryRequest.getStatus().name());
dto.setSearchResults(inquiryRequest.getSearchResults());
dto.setResponseContent(inquiryRequest.getResponseContent());

View File

@ -0,0 +1,223 @@
package com.ipsen.medical.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ipsen.medical.dto.ClinicalTrialDTO;
import com.ipsen.medical.dto.KeywordConfirmationDTO;
import com.ipsen.medical.dto.SearchResultItemDTO;
import com.ipsen.medical.entity.InquiryRequest;
import com.ipsen.medical.repository.InquiryRequestRepository;
import com.ipsen.medical.service.ClinicalTrialsService;
import com.ipsen.medical.service.DifyService;
import com.ipsen.medical.service.KnowledgeBaseService;
import com.ipsen.medical.service.MultiSourceSearchService;
import com.ipsen.medical.service.SearchResultService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
/**
* 多源检索服务实现
*/
@Slf4j
@Service
@Transactional
public class MultiSourceSearchServiceImpl implements MultiSourceSearchService {
@Autowired
private InquiryRequestRepository inquiryRequestRepository;
@Autowired
private SearchResultService searchResultService;
@Autowired
private ClinicalTrialsService clinicalTrialsService;
@Autowired
private KnowledgeBaseService knowledgeBaseService;
@Autowired
private DifyService difyService;
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public List<SearchResultItemDTO> performMultiSourceSearch(Long inquiryId) {
log.info("Performing multi-source search for inquiry: {}", inquiryId);
InquiryRequest inquiry = inquiryRequestRepository.findById(inquiryId)
.orElseThrow(() -> new RuntimeException("查询请求不存在: " + inquiryId));
// 解析关键词
String keywords = inquiry.getKeywords();
if (keywords == null || keywords.isEmpty()) {
throw new RuntimeException("关键词未提取,无法执行检索");
}
List<SearchResultItemDTO> allResults = new ArrayList<>();
try {
// 根据用户选择的数据源进行检索
if (Boolean.TRUE.equals(inquiry.getSearchInternalData())) {
log.info("Searching internal data");
List<SearchResultItemDTO> internalResults = searchInternalData(inquiryId, keywords);
allResults.addAll(internalResults);
}
if (Boolean.TRUE.equals(inquiry.getSearchKnowledgeBase())) {
log.info("Searching knowledge base");
List<SearchResultItemDTO> kbResults = searchKnowledgeBase(inquiryId, keywords);
allResults.addAll(kbResults);
}
if (Boolean.TRUE.equals(inquiry.getSearchCnki())) {
log.info("Searching CNKI");
List<SearchResultItemDTO> cnkiResults = searchCnki(inquiryId, keywords);
allResults.addAll(cnkiResults);
}
if (Boolean.TRUE.equals(inquiry.getSearchClinicalTrials())) {
log.info("Searching ClinicalTrials");
List<SearchResultItemDTO> ctResults = searchClinicalTrials(inquiryId, keywords);
allResults.addAll(ctResults);
}
// 更新查询请求状态
inquiry.setStatus(InquiryRequest.RequestStatus.SEARCH_COMPLETED);
inquiryRequestRepository.save(inquiry);
log.info("Multi-source search completed, found {} results", allResults.size());
return allResults;
} catch (Exception e) {
log.error("Error performing multi-source search: ", e);
throw new RuntimeException("多源检索失败: " + e.getMessage(), e);
}
}
@Override
public List<SearchResultItemDTO> searchInternalData(Long inquiryId, String keywords) {
log.info("Searching internal data for inquiry: {}", inquiryId);
List<SearchResultItemDTO> results = new ArrayList<>();
// TODO: 实现内部数据检索逻辑
// 这里应该查询企业内部研究数据历史回复等
log.warn("Internal data search not yet implemented");
return results;
}
@Override
public List<SearchResultItemDTO> searchKnowledgeBase(Long inquiryId, String keywords) {
log.info("Searching knowledge base for inquiry: {}", inquiryId);
List<SearchResultItemDTO> results = new ArrayList<>();
try {
// 使用Dify服务搜索知识库
String searchResults = difyService.performSearch(keywords);
// 解析搜索结果并转换为SearchResultItemDTO
// TODO: 根据实际返回格式解析
SearchResultItemDTO result = new SearchResultItemDTO();
result.setInquiryRequestId(inquiryId);
result.setTitle("知识库检索结果");
result.setContent(searchResults);
result.setSource("知识库");
SearchResultItemDTO created = searchResultService.createSearchResult(result);
results.add(created);
} catch (Exception e) {
log.error("Error searching knowledge base: ", e);
}
return results;
}
@Override
public List<SearchResultItemDTO> searchCnki(Long inquiryId, String keywords) {
log.info("Searching CNKI for inquiry: {}", inquiryId);
List<SearchResultItemDTO> results = new ArrayList<>();
// TODO: 实现知网检索逻辑
// 这里应该调用知网API或爬虫
log.warn("CNKI search not yet implemented");
return results;
}
@Override
public List<SearchResultItemDTO> searchClinicalTrials(Long inquiryId, String keywords) {
log.info("Searching ClinicalTrials for inquiry: {}", inquiryId);
List<SearchResultItemDTO> results = new ArrayList<>();
try {
// 解析关键词
KeywordConfirmationDTO keywordDTO = objectMapper.readValue(keywords, KeywordConfirmationDTO.class);
String searchKeyword = buildSearchKeyword(keywordDTO);
// 使用ClinicalTrialsService搜索
List<ClinicalTrialDTO> trials = clinicalTrialsService.searchAndSaveForInquiry(inquiryId, searchKeyword);
// 转换为SearchResultItemDTO
for (ClinicalTrialDTO trial : trials) {
SearchResultItemDTO result = new SearchResultItemDTO();
result.setInquiryRequestId(inquiryId);
result.setTitle(trial.getStudyTitle() != null ? trial.getStudyTitle() : trial.getBriefTitle());
result.setSummary(trial.getBriefSummary());
result.setNctId(trial.getNctId());
result.setSource("ClinicalTrials.gov");
result.setSourceUrl("https://clinicaltrials.gov/study/" + trial.getNctId());
result.setPublicationDate(trial.getStartDate());
SearchResultItemDTO created = searchResultService.createSearchResult(result);
results.add(created);
}
} catch (Exception e) {
log.error("Error searching ClinicalTrials: ", e);
}
return results;
}
/**
* 构建搜索关键词
*/
private String buildSearchKeyword(KeywordConfirmationDTO keywordDTO) {
StringBuilder sb = new StringBuilder();
if (keywordDTO.getDrugNameEnglish() != null && !keywordDTO.getDrugNameEnglish().isEmpty()) {
sb.append(keywordDTO.getDrugNameEnglish());
} else if (keywordDTO.getDrugNameChinese() != null && !keywordDTO.getDrugNameChinese().isEmpty()) {
sb.append(keywordDTO.getDrugNameChinese());
}
if (keywordDTO.getRequestItem() != null && !keywordDTO.getRequestItem().isEmpty()) {
if (sb.length() > 0) {
sb.append(" ");
}
sb.append(keywordDTO.getRequestItem());
}
if (keywordDTO.getAdditionalKeywords() != null && !keywordDTO.getAdditionalKeywords().isEmpty()) {
for (String keyword : keywordDTO.getAdditionalKeywords()) {
if (sb.length() > 0) {
sb.append(" ");
}
sb.append(keyword);
}
}
return sb.toString();
}
}

View File

@ -0,0 +1,201 @@
package com.ipsen.medical.service.impl;
import com.ipsen.medical.dto.SearchResultItemDTO;
import com.ipsen.medical.entity.InquiryRequest;
import com.ipsen.medical.entity.SearchResultItem;
import com.ipsen.medical.repository.InquiryRequestRepository;
import com.ipsen.medical.repository.SearchResultItemRepository;
import com.ipsen.medical.service.SearchResultService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 检索结果服务实现
*/
@Slf4j
@Service
@Transactional
public class SearchResultServiceImpl implements SearchResultService {
@Autowired
private SearchResultItemRepository searchResultItemRepository;
@Autowired
private InquiryRequestRepository inquiryRequestRepository;
@Override
public List<SearchResultItemDTO> getSearchResults(Long inquiryRequestId) {
List<SearchResultItem> items = searchResultItemRepository.findByInquiryRequestId(inquiryRequestId);
return items.stream()
.map(this::convertToDTO)
.collect(Collectors.toList());
}
@Override
public List<SearchResultItemDTO> getActiveSearchResults(Long inquiryRequestId) {
List<SearchResultItem> items = searchResultItemRepository.findByInquiryRequestIdAndIsDeletedFalse(inquiryRequestId);
return items.stream()
.map(this::convertToDTO)
.collect(Collectors.toList());
}
@Override
public SearchResultItemDTO updateSearchResult(Long id, SearchResultItemDTO dto) {
SearchResultItem item = searchResultItemRepository.findById(id)
.orElseThrow(() -> new RuntimeException("检索结果不存在: " + id));
if (dto.getIncludeInResponse() != null) {
item.setIncludeInResponse(dto.getIncludeInResponse());
}
if (dto.getNeedDownload() != null) {
item.setNeedDownload(dto.getNeedDownload());
if (dto.getNeedDownload() && item.getDownloadStatus() == SearchResultItem.DownloadStatus.NOT_REQUIRED) {
item.setDownloadStatus(SearchResultItem.DownloadStatus.PENDING);
} else if (!dto.getNeedDownload()) {
item.setDownloadStatus(SearchResultItem.DownloadStatus.NOT_REQUIRED);
}
}
if (dto.getIsDeleted() != null) {
item.setIsDeleted(dto.getIsDeleted());
if (dto.getIsDeleted()) {
item.setStatus(SearchResultItem.ResultStatus.DELETED);
}
}
if (dto.getStatus() != null) {
item.setStatus(SearchResultItem.ResultStatus.valueOf(dto.getStatus()));
}
item.setUpdatedAt(LocalDateTime.now());
SearchResultItem saved = searchResultItemRepository.save(item);
return convertToDTO(saved);
}
@Override
public List<SearchResultItemDTO> batchUpdateSearchResults(List<SearchResultItemDTO> dtos) {
return dtos.stream()
.map(dto -> updateSearchResult(dto.getId(), dto))
.collect(Collectors.toList());
}
@Override
public void markAsDeleted(Long id) {
SearchResultItem item = searchResultItemRepository.findById(id)
.orElseThrow(() -> new RuntimeException("检索结果不存在: " + id));
item.setIsDeleted(true);
item.setStatus(SearchResultItem.ResultStatus.DELETED);
item.setUpdatedAt(LocalDateTime.now());
searchResultItemRepository.save(item);
}
@Override
public SearchResultItemDTO setIncludeInResponse(Long id, Boolean include) {
SearchResultItem item = searchResultItemRepository.findById(id)
.orElseThrow(() -> new RuntimeException("检索结果不存在: " + id));
item.setIncludeInResponse(include);
item.setUpdatedAt(LocalDateTime.now());
SearchResultItem saved = searchResultItemRepository.save(item);
return convertToDTO(saved);
}
@Override
public SearchResultItemDTO setNeedDownload(Long id, Boolean need) {
SearchResultItem item = searchResultItemRepository.findById(id)
.orElseThrow(() -> new RuntimeException("检索结果不存在: " + id));
item.setNeedDownload(need);
if (need && item.getDownloadStatus() == SearchResultItem.DownloadStatus.NOT_REQUIRED) {
item.setDownloadStatus(SearchResultItem.DownloadStatus.PENDING);
} else if (!need) {
item.setDownloadStatus(SearchResultItem.DownloadStatus.NOT_REQUIRED);
}
item.setUpdatedAt(LocalDateTime.now());
SearchResultItem saved = searchResultItemRepository.save(item);
return convertToDTO(saved);
}
@Override
public List<SearchResultItemDTO> getPendingDownloads() {
List<SearchResultItem> items = searchResultItemRepository.findByDownloadStatus(
SearchResultItem.DownloadStatus.PENDING);
return items.stream()
.map(this::convertToDTO)
.collect(Collectors.toList());
}
@Override
public List<SearchResultItemDTO> getPendingDownloadsByInquiry(Long inquiryRequestId) {
List<SearchResultItem> items = searchResultItemRepository.findByInquiryRequestIdAndNeedDownloadTrue(inquiryRequestId);
return items.stream()
.filter(item -> item.getDownloadStatus() == SearchResultItem.DownloadStatus.PENDING ||
item.getDownloadStatus() == SearchResultItem.DownloadStatus.FAILED)
.map(this::convertToDTO)
.collect(Collectors.toList());
}
@Override
public SearchResultItemDTO createSearchResult(SearchResultItemDTO dto) {
InquiryRequest inquiry = inquiryRequestRepository.findById(dto.getInquiryRequestId())
.orElseThrow(() -> new RuntimeException("查询请求不存在: " + dto.getInquiryRequestId()));
SearchResultItem item = new SearchResultItem();
item.setInquiryRequest(inquiry);
item.setTitle(dto.getTitle());
item.setSummary(dto.getSummary());
item.setContent(dto.getContent());
item.setAuthors(dto.getAuthors());
item.setSource(dto.getSource());
item.setSourceUrl(dto.getSourceUrl());
item.setPublicationDate(dto.getPublicationDate());
item.setDoi(dto.getDoi());
item.setPmid(dto.getPmid());
item.setNctId(dto.getNctId());
item.setMetadata(dto.getMetadata());
SearchResultItem saved = searchResultItemRepository.save(item);
return convertToDTO(saved);
}
@Override
public List<SearchResultItemDTO> batchCreateSearchResults(List<SearchResultItemDTO> dtos) {
return dtos.stream()
.map(this::createSearchResult)
.collect(Collectors.toList());
}
/**
* 转换为DTO
*/
private SearchResultItemDTO convertToDTO(SearchResultItem item) {
SearchResultItemDTO dto = new SearchResultItemDTO();
dto.setId(item.getId());
dto.setInquiryRequestId(item.getInquiryRequest().getId());
dto.setTitle(item.getTitle());
dto.setSummary(item.getSummary());
dto.setContent(item.getContent());
dto.setAuthors(item.getAuthors());
dto.setSource(item.getSource());
dto.setSourceUrl(item.getSourceUrl());
dto.setPublicationDate(item.getPublicationDate());
dto.setDoi(item.getDoi());
dto.setPmid(item.getPmid());
dto.setNctId(item.getNctId());
dto.setMetadata(item.getMetadata());
dto.setStatus(item.getStatus().name());
dto.setIncludeInResponse(item.getIncludeInResponse());
dto.setNeedDownload(item.getNeedDownload());
dto.setIsDeleted(item.getIsDeleted());
dto.setFilePath(item.getFilePath());
dto.setDownloadStatus(item.getDownloadStatus().name());
dto.setCreatedAt(item.getCreatedAt());
dto.setUpdatedAt(item.getUpdatedAt());
dto.setDownloadedAt(item.getDownloadedAt());
return dto;
}
}

View File

@ -38,7 +38,7 @@ app:
# Dify配置
dify:
api-url: ${DIFY_API_URL:https://api.dify.ai/v1}
api-key: ${DIFY_API_KEY:app-croZF0SSV5fiyXbK3neGrOT6}
api-key: ${DIFY_API_KEY:your-dify-api-key-here}
# 大模型配置
llm:

View File

@ -213,3 +213,5 @@ SELECT '药物模块初始化完成!' AS message,

View File

@ -0,0 +1,140 @@
-- 添加检索结果项表和更新查询请求表
-- Migration script for search result items and inquiry request enhancements
USE medical_info_system;
-- 1. 更新查询请求表,添加新字段
-- 检查并添加 keywords_confirmed 字段
SET @sql = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE table_name = 'inquiry_requests'
AND table_schema = DATABASE()
AND column_name = 'keywords_confirmed') = 0,
'ALTER TABLE inquiry_requests ADD COLUMN keywords_confirmed BOOLEAN DEFAULT FALSE COMMENT ''用户是否确认关键词''',
'SELECT ''Column keywords_confirmed already exists'' as message'
));
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 检查并添加 search_internal_data 字段
SET @sql = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE table_name = 'inquiry_requests'
AND table_schema = DATABASE()
AND column_name = 'search_internal_data') = 0,
'ALTER TABLE inquiry_requests ADD COLUMN search_internal_data BOOLEAN DEFAULT FALSE COMMENT ''是否在内部数据中检索''',
'SELECT ''Column search_internal_data already exists'' as message'
));
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 检查并添加 search_knowledge_base 字段
SET @sql = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE table_name = 'inquiry_requests'
AND table_schema = DATABASE()
AND column_name = 'search_knowledge_base') = 0,
'ALTER TABLE inquiry_requests ADD COLUMN search_knowledge_base BOOLEAN DEFAULT FALSE COMMENT ''是否在知识库中检索''',
'SELECT ''Column search_knowledge_base already exists'' as message'
));
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 检查并添加 search_cnki 字段
SET @sql = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE table_name = 'inquiry_requests'
AND table_schema = DATABASE()
AND column_name = 'search_cnki') = 0,
'ALTER TABLE inquiry_requests ADD COLUMN search_cnki BOOLEAN DEFAULT FALSE COMMENT ''是否在知网中检索''',
'SELECT ''Column search_cnki already exists'' as message'
));
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 检查并添加 search_clinical_trials 字段
SET @sql = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE table_name = 'inquiry_requests'
AND table_schema = DATABASE()
AND column_name = 'search_clinical_trials') = 0,
'ALTER TABLE inquiry_requests ADD COLUMN search_clinical_trials BOOLEAN DEFAULT FALSE COMMENT ''是否在ClinicalTrials中检索''',
'SELECT ''Column search_clinical_trials already exists'' as message'
));
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 2. 修改查询请求表使customer_name为可选
ALTER TABLE inquiry_requests
MODIFY COLUMN customer_name VARCHAR(100) NULL COMMENT '客户姓名(非必填)';
-- 3. 修改查询请求表使inquiry_content为必填
ALTER TABLE inquiry_requests
MODIFY COLUMN inquiry_content TEXT NOT NULL COMMENT '查询内容(必填)';
-- 4. 创建检索结果项表
CREATE TABLE IF NOT EXISTS search_result_items (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
inquiry_request_id BIGINT NOT NULL COMMENT '查询请求ID',
title VARCHAR(500) NOT NULL COMMENT '标题',
summary TEXT COMMENT '摘要',
content TEXT COMMENT '内容',
authors VARCHAR(500) COMMENT '作者',
source VARCHAR(100) COMMENT '来源知网、ClinicalTrials、知识库等',
source_url VARCHAR(500) COMMENT '来源URL',
publication_date VARCHAR(50) COMMENT '发表日期',
doi VARCHAR(100) COMMENT 'DOI',
pmid VARCHAR(50) COMMENT 'PubMed ID',
nct_id VARCHAR(50) COMMENT 'ClinicalTrials NCT ID',
metadata TEXT COMMENT '其他元数据JSON格式',
status VARCHAR(20) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '结果状态: PENDING_REVIEW, APPROVED, REJECTED, DELETED',
include_in_response BOOLEAN DEFAULT FALSE COMMENT '是否纳入回复参考资料',
need_download BOOLEAN DEFAULT FALSE COMMENT '是否需要下载全文',
is_deleted BOOLEAN DEFAULT FALSE COMMENT '是否标记为错误并删除',
file_path VARCHAR(500) COMMENT '下载后的文件路径',
download_status VARCHAR(20) DEFAULT 'NOT_REQUIRED' COMMENT '下载状态: NOT_REQUIRED, PENDING, DOWNLOADING, COMPLETED, FAILED',
created_at DATETIME NOT NULL,
updated_at DATETIME,
downloaded_at DATETIME,
INDEX idx_inquiry_request_id (inquiry_request_id),
INDEX idx_source (source),
INDEX idx_status (status),
INDEX idx_include_in_response (include_in_response),
INDEX idx_need_download (need_download),
INDEX idx_is_deleted (is_deleted),
INDEX idx_download_status (download_status),
FOREIGN KEY (inquiry_request_id) REFERENCES inquiry_requests(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='检索结果项表';
-- 5. 添加索引以提高查询性能
-- 检查并创建 idx_keywords_confirmed 索引
SET @sql = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE table_name = 'inquiry_requests'
AND table_schema = DATABASE()
AND index_name = 'idx_keywords_confirmed') = 0,
'CREATE INDEX idx_keywords_confirmed ON inquiry_requests(keywords_confirmed)',
'SELECT ''Index idx_keywords_confirmed already exists'' as message'
));
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 检查并创建 idx_search_flags 索引
SET @sql = (SELECT IF(
(SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE table_name = 'inquiry_requests'
AND table_schema = DATABASE()
AND index_name = 'idx_search_flags') = 0,
'CREATE INDEX idx_search_flags ON inquiry_requests(search_internal_data, search_knowledge_base, search_cnki, search_clinical_trials)',
'SELECT ''Index idx_search_flags already exists'' as message'
));
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -1,49 +0,0 @@
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker Compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/
# Here the instructions define your application as a service called "server".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
server:
build:
context: .
ports:
- 8080:8080
# The commented out section below is an example of how to define a PostgreSQL
# database that your application can use. `depends_on` tells Docker Compose to
# start the database before your application. The `db-data` volume persists the
# database data between container restarts. The `db-password` secret is used
# to set the database password. You must create `db/password.txt` and add
# a password of your choosing to it before running `docker-compose up`.
# depends_on:
# db:
# condition: service_healthy
# db:
# image: postgres
# restart: always
# user: postgres
# secrets:
# - db-password
# volumes:
# - db-data:/var/lib/postgresql/data
# environment:
# - POSTGRES_DB=example
# - POSTGRES_PASSWORD_FILE=/run/secrets/db-password
# expose:
# - 5432
# healthcheck:
# test: [ "CMD", "pg_isready" ]
# interval: 10s
# timeout: 5s
# retries: 5
# volumes:
# db-data:
# secrets:
# db-password:
# file: db/password.txt

View File

@ -10,6 +10,7 @@ SPRING_PROFILES_ACTIVE=prod
JWT_SECRET=your-secret-key-change-in-production-environment
# Dify配置
# 请将下面的API Key替换为您实际的Dify API Key
DIFY_API_URL=https://api.dify.ai/v1
DIFY_API_KEY=your-dify-api-key-here

View File

@ -1,34 +0,0 @@
# 构建阶段
FROM node:18-alpine AS build
WORKDIR /app
# 复制package.json和package-lock.json
COPY package*.json ./
RUN npm install
# 复制源代码并构建
COPY . .
RUN npm run build
# 运行阶段
FROM nginx:alpine
WORKDIR /usr/share/nginx/html
# 删除默认的nginx静态资源
RUN rm -rf ./*
# 从构建阶段复制构建结果
COPY --from=build /app/dist .
# 复制nginx配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 暴露端口
EXPOSE 80
# 启动nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@ -1,41 +0,0 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
# 前端路由支持
location / {
try_files $uri $uri/ /index.html;
}
# API代理
location /api/ {
proxy_pass http://backend:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 超时设置
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# 缓存静态资源
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View File

@ -160,6 +160,28 @@ export function executeFullWorkflow(id) {
})
}
/**
* 确认和更新关键词
*/
export function confirmKeywords(id, data) {
return request({
url: `/inquiries/${id}/confirm-keywords`,
method: 'post',
data
})
}
/**
* 选择数据源
*/
export function selectDataSources(id, data) {
return request({
url: `/inquiries/${id}/select-data-sources`,
method: 'post',
data
})
}

View File

@ -0,0 +1,109 @@
import request from '@/utils/request'
/**
* 获取查询请求的所有检索结果
*/
export function getSearchResults(inquiryId) {
return request({
url: `/search-results/inquiry/${inquiryId}`,
method: 'get'
})
}
/**
* 更新检索结果项
*/
export function updateSearchResult(id, data) {
return request({
url: `/search-results/${id}`,
method: 'put',
data
})
}
/**
* 批量更新检索结果
*/
export function batchUpdateSearchResults(data) {
return request({
url: '/search-results/batch',
method: 'put',
data
})
}
/**
* 标记为错误并删除
*/
export function deleteSearchResult(id) {
return request({
url: `/search-results/${id}`,
method: 'delete'
})
}
/**
* 设置是否纳入回复参考资料
*/
export function setIncludeInResponse(id, include) {
return request({
url: `/search-results/${id}/include`,
method: 'post',
params: { include }
})
}
/**
* 设置是否需要下载全文
*/
export function setNeedDownload(id, need) {
return request({
url: `/search-results/${id}/download`,
method: 'post',
params: { need }
})
}
/**
* 获取所有待下载的结果项
*/
export function getPendingDownloads() {
return request({
url: '/download-tasks/pending',
method: 'get'
})
}
/**
* 获取查询请求的待下载结果项
*/
export function getPendingDownloadsByInquiry(inquiryId) {
return request({
url: `/download-tasks/inquiry/${inquiryId}/pending`,
method: 'get'
})
}
/**
* 标记下载完成
*/
export function markDownloadComplete(id, filePath) {
return request({
url: `/download-tasks/${id}/complete`,
method: 'post',
params: { filePath }
})
}
/**
* 标记下载失败
*/
export function markDownloadFailed(id, reason) {
return request({
url: `/download-tasks/${id}/fail`,
method: 'post',
params: { reason }
})
}

View File

@ -45,6 +45,40 @@ const routes = [
component: () => import('@/views/inquiry/InquiryDetail.vue'),
meta: { title: '查询详情', icon: 'View' },
hidden: true
},
{
path: ':id/keyword-confirmation',
name: 'KeywordConfirmation',
component: () => import('@/views/inquiry/KeywordConfirmation.vue'),
meta: { title: '关键词确认', icon: 'Key' },
hidden: true
},
{
path: ':id/data-source-selection',
name: 'DataSourceSelection',
component: () => import('@/views/inquiry/DataSourceSelection.vue'),
meta: { title: '数据源选择', icon: 'DataBoard' },
hidden: true
},
{
path: ':id/search-results',
name: 'SearchResults',
component: () => import('@/views/inquiry/SearchResults.vue'),
meta: { title: '检索结果管理', icon: 'Search' },
hidden: true
},
{
path: ':id/response-view',
name: 'ResponseView',
component: () => import('@/views/inquiry/ResponseView.vue'),
meta: { title: '回复查看', icon: 'Document' },
hidden: true
},
{
path: 'download-tasks',
name: 'DownloadTasks',
component: () => import('@/views/inquiry/DownloadTasks.vue'),
meta: { title: '待下载任务', icon: 'Download' }
}
]
},

View File

@ -0,0 +1,437 @@
<template>
<div class="data-source-selection">
<el-card v-loading="loading">
<template #header>
<div class="card-header">
<span>选择数据源 - {{ inquiry.requestNumber }}</span>
<el-button @click="goBack" text>返回</el-button>
</div>
</template>
<!-- 关键词展示 -->
<el-alert
title="已确认的关键词"
type="success"
:closable="false"
style="margin-bottom: 20px;"
>
<div class="keywords-display">
<el-tag
v-if="keywords.drugNameChinese"
type="success"
size="large"
style="margin-right: 10px;"
>
{{ keywords.drugNameChinese }}
</el-tag>
<el-tag
v-if="keywords.drugNameEnglish"
type="primary"
size="large"
style="margin-right: 10px;"
>
{{ keywords.drugNameEnglish }}
</el-tag>
<el-tag
v-if="keywords.requestItem"
type="warning"
size="large"
style="margin-right: 10px;"
>
{{ keywords.requestItem }}
</el-tag>
<el-tag
v-for="(tag, index) in keywords.additionalKeywords"
:key="index"
type="info"
size="large"
style="margin-right: 10px;"
>
{{ tag }}
</el-tag>
</div>
</el-alert>
<!-- 数据源选择 -->
<div class="source-selection">
<h3 style="margin-bottom: 20px;">请选择需要检索的数据源</h3>
<el-form :model="form" label-position="top">
<el-row :gutter="20">
<el-col :span="12">
<el-card
shadow="hover"
:class="{ 'source-card': true, 'selected': form.searchInternalData }"
@click="toggleSource('searchInternalData')"
>
<div class="source-content">
<el-checkbox
v-model="form.searchInternalData"
size="large"
@click.stop
/>
<div class="source-info">
<div class="source-title">
<el-icon :size="24" color="#409EFF"><Document /></el-icon>
<span>内部数据</span>
</div>
<div class="source-desc">
企业自有研究数据历史回复记录内部文献等
</div>
<el-tag size="small" type="primary">企业数据</el-tag>
</div>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card
shadow="hover"
:class="{ 'source-card': true, 'selected': form.searchKnowledgeBase }"
@click="toggleSource('searchKnowledgeBase')"
>
<div class="source-content">
<el-checkbox
v-model="form.searchKnowledgeBase"
size="large"
@click.stop
/>
<div class="source-info">
<div class="source-title">
<el-icon :size="24" color="#67C23A"><FolderOpened /></el-icon>
<span>知识库</span>
</div>
<div class="source-desc">
已整理的企业知识库数据专家意见等
</div>
<el-tag size="small" type="success">推荐</el-tag>
</div>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card
shadow="hover"
:class="{ 'source-card': true, 'selected': form.searchCnki }"
@click="toggleSource('searchCnki')"
>
<div class="source-content">
<el-checkbox
v-model="form.searchCnki"
size="large"
@click.stop
/>
<div class="source-info">
<div class="source-title">
<el-icon :size="24" color="#E6A23C"><Reading /></el-icon>
<span>知网 (CNKI)</span>
</div>
<div class="source-desc">
中国知网学术文献数据库
</div>
<el-tag size="small" type="warning">中文文献</el-tag>
</div>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card
shadow="hover"
:class="{ 'source-card': true, 'selected': form.searchClinicalTrials }"
@click="toggleSource('searchClinicalTrials')"
>
<div class="source-content">
<el-checkbox
v-model="form.searchClinicalTrials"
size="large"
@click.stop
/>
<div class="source-info">
<div class="source-title">
<el-icon :size="24" color="#F56C6C"><Experiment /></el-icon>
<span>ClinicalTrials.gov</span>
</div>
<div class="source-desc">
美国国家医学图书馆临床试验数据库
</div>
<el-tag size="small" type="danger">临床试验</el-tag>
</div>
</div>
</el-card>
</el-col>
</el-row>
</el-form>
<!-- 选择提示 -->
<el-alert
v-if="!hasSelectedSource"
title="请至少选择一个数据源"
type="warning"
:closable="false"
style="margin-top: 20px;"
/>
<el-alert
v-else
:title="`已选择 ${selectedCount} 个数据源`"
type="info"
:closable="false"
style="margin-top: 20px;"
>
<div>将在以下数据源中进行检索{{ selectedSourcesText }}</div>
</el-alert>
<!-- 操作按钮 -->
<div class="action-buttons">
<el-button
type="primary"
size="large"
@click="handleConfirm"
:loading="confirming"
:disabled="!hasSelectedSource"
>
确认并开始检索
</el-button>
<el-button size="large" @click="handleSelectAll">
全选
</el-button>
<el-button size="large" @click="handleClearAll">
清空
</el-button>
<el-button size="large" @click="goBack">
返回
</el-button>
</div>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { getInquiryDetail, selectDataSources, performSearch } from '@/api/inquiry'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Document, FolderOpened, Reading, Experiment } from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const confirming = ref(false)
const inquiry = ref({})
const keywords = ref({})
const form = reactive({
searchInternalData: false,
searchKnowledgeBase: true, //
searchCnki: false,
searchClinicalTrials: true //
})
const hasSelectedSource = computed(() => {
return form.searchInternalData ||
form.searchKnowledgeBase ||
form.searchCnki ||
form.searchClinicalTrials
})
const selectedCount = computed(() => {
let count = 0
if (form.searchInternalData) count++
if (form.searchKnowledgeBase) count++
if (form.searchCnki) count++
if (form.searchClinicalTrials) count++
return count
})
const selectedSourcesText = computed(() => {
const sources = []
if (form.searchInternalData) sources.push('内部数据')
if (form.searchKnowledgeBase) sources.push('知识库')
if (form.searchCnki) sources.push('知网')
if (form.searchClinicalTrials) sources.push('ClinicalTrials.gov')
return sources.join('、')
})
onMounted(() => {
loadDetail()
})
const loadDetail = async () => {
loading.value = true
try {
const id = route.params.id
const data = await getInquiryDetail(id)
inquiry.value = data
//
if (data.keywords) {
try {
keywords.value = JSON.parse(data.keywords)
} catch (error) {
console.error('解析关键词失败', error)
}
}
//
if (data.searchInternalData !== null) {
form.searchInternalData = data.searchInternalData || false
}
if (data.searchKnowledgeBase !== null) {
form.searchKnowledgeBase = data.searchKnowledgeBase || false
}
if (data.searchCnki !== null) {
form.searchCnki = data.searchCnki || false
}
if (data.searchClinicalTrials !== null) {
form.searchClinicalTrials = data.searchClinicalTrials || false
}
} catch (error) {
ElMessage.error('加载详情失败')
} finally {
loading.value = false
}
}
const toggleSource = (source) => {
form[source] = !form[source]
}
const handleSelectAll = () => {
form.searchInternalData = true
form.searchKnowledgeBase = true
form.searchCnki = true
form.searchClinicalTrials = true
}
const handleClearAll = () => {
form.searchInternalData = false
form.searchKnowledgeBase = false
form.searchCnki = false
form.searchClinicalTrials = false
}
const handleConfirm = async () => {
if (!hasSelectedSource.value) {
ElMessage.warning('请至少选择一个数据源')
return
}
ElMessageBox.confirm(
`确认在以下数据源中检索:${selectedSourcesText.value}`,
'确认检索',
{
confirmButtonText: '开始检索',
cancelButtonText: '取消',
type: 'info'
}
).then(async () => {
confirming.value = true
try {
//
await selectDataSources(inquiry.value.id, form)
ElMessage.success('数据源选择已保存')
//
await performSearch(inquiry.value.id)
ElMessage.success('检索已完成,正在跳转到结果页面...')
//
router.push(`/inquiry/${inquiry.value.id}/search-results`)
} catch (error) {
ElMessage.error('操作失败: ' + (error.message || '未知错误'))
} finally {
confirming.value = false
}
}).catch(() => {
//
})
}
const goBack = () => {
router.back()
}
</script>
<style scoped>
.data-source-selection {
width: 100%;
max-width: 1200px;
margin: 0 auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.keywords-display {
padding: 10px 0;
}
.source-selection {
padding: 20px 0;
}
.source-card {
cursor: pointer;
transition: all 0.3s;
margin-bottom: 20px;
}
.source-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.source-card.selected {
border: 2px solid #409EFF;
background-color: #ecf5ff;
}
.source-content {
display: flex;
align-items: flex-start;
gap: 15px;
}
.source-info {
flex: 1;
}
.source-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 18px;
font-weight: 600;
margin-bottom: 10px;
color: #303133;
}
.source-desc {
color: #606266;
line-height: 1.6;
margin-bottom: 10px;
font-size: 14px;
}
.action-buttons {
margin-top: 30px;
text-align: center;
}
.action-buttons .el-button {
margin: 0 10px;
}
:deep(.el-checkbox__label) {
display: none;
}
</style>

View File

@ -0,0 +1,484 @@
<template>
<div class="download-tasks">
<el-card v-loading="loading">
<template #header>
<div class="card-header">
<span>待下载任务</span>
<div>
<el-button @click="handleRefresh" icon="Refresh" :loading="loading">
刷新
</el-button>
<el-button @click="goBack" text>返回</el-button>
</div>
</div>
</template>
<!-- 统计信息 -->
<el-row :gutter="20" style="margin-bottom: 20px;">
<el-col :span="6">
<el-statistic title="待下载任务" :value="pendingTasks.length" value-style="color: #E6A23C">
<template #suffix></template>
</el-statistic>
</el-col>
<el-col :span="6">
<el-statistic title="下载中" :value="downloadingCount" value-style="color: #409EFF">
<template #suffix></template>
</el-statistic>
</el-col>
<el-col :span="6">
<el-statistic title="已完成" :value="completedCount" value-style="color: #67C23A">
<template #suffix></template>
</el-statistic>
</el-col>
<el-col :span="6">
<el-statistic title="失败" :value="failedCount" value-style="color: #F56C6C">
<template #suffix></template>
</el-statistic>
</el-col>
</el-row>
<!-- 说明信息 -->
<el-alert
title="关于自动化下载"
type="info"
:closable="false"
style="margin-bottom: 20px;"
>
<div>
此页面显示所有待下载的文献列表可供自动化下载工具读取并执行下载任务
<br />
下载完成后请更新任务状态
</div>
</el-alert>
<!-- 筛选器 -->
<div class="filters">
<el-radio-group v-model="filterStatus" @change="loadTasks">
<el-radio-button label="all">全部任务</el-radio-button>
<el-radio-button label="PENDING">待下载</el-radio-button>
<el-radio-button label="DOWNLOADING">下载中</el-radio-button>
<el-radio-button label="COMPLETED">已完成</el-radio-button>
<el-radio-button label="FAILED">失败</el-radio-button>
</el-radio-group>
<el-select
v-model="selectedInquiryId"
placeholder="筛选查询请求"
clearable
style="width: 300px; margin-left: 15px;"
@change="loadTasks"
>
<el-option
v-for="inquiry in uniqueInquiries"
:key="inquiry.id"
:label="`${inquiry.requestNumber} - ${inquiry.title}`"
:value="inquiry.id"
/>
</el-select>
</div>
<!-- 任务列表 -->
<el-table :data="displayTasks" border stripe style="margin-top: 20px;">
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-content">
<el-descriptions :column="2" border>
<el-descriptions-item label="任务ID">
{{ row.id }}
</el-descriptions-item>
<el-descriptions-item label="查询请求ID">
<el-link
type="primary"
@click="goToInquiry(row.inquiryRequestId)"
>
#{{ row.inquiryRequestId }}
</el-link>
</el-descriptions-item>
<el-descriptions-item label="标题" :span="2">
{{ row.title }}
</el-descriptions-item>
<el-descriptions-item label="作者" :span="2">
{{ row.authors || 'N/A' }}
</el-descriptions-item>
<el-descriptions-item label="摘要" :span="2">
<div style="white-space: pre-wrap; max-height: 150px; overflow-y: auto;">
{{ row.summary || 'N/A' }}
</div>
</el-descriptions-item>
<el-descriptions-item label="DOI" v-if="row.doi">
<a :href="`https://doi.org/${row.doi}`" target="_blank" style="color: #409EFF;">
{{ row.doi }}
</a>
</el-descriptions-item>
<el-descriptions-item label="PMID" v-if="row.pmid">
<a :href="`https://pubmed.ncbi.nlm.nih.gov/${row.pmid}`" target="_blank" style="color: #409EFF;">
{{ row.pmid }}
</a>
</el-descriptions-item>
<el-descriptions-item label="NCT ID" v-if="row.nctId">
<a :href="`https://clinicaltrials.gov/study/${row.nctId}`" target="_blank" style="color: #409EFF;">
{{ row.nctId }}
</a>
</el-descriptions-item>
<el-descriptions-item label="来源链接" :span="2" v-if="row.sourceUrl">
<a :href="row.sourceUrl" target="_blank" style="color: #409EFF; word-break: break-all;">
{{ row.sourceUrl }}
</a>
</el-descriptions-item>
<el-descriptions-item label="文件路径" :span="2" v-if="row.filePath">
{{ row.filePath }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatDateTime(row.createdAt) }}
</el-descriptions-item>
<el-descriptions-item label="下载时间" v-if="row.downloadedAt">
{{ formatDateTime(row.downloadedAt) }}
</el-descriptions-item>
</el-descriptions>
</div>
</template>
</el-table-column>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="title" label="标题" min-width="300" show-overflow-tooltip />
<el-table-column prop="source" label="来源" width="150">
<template #default="{ row }">
<el-tag :type="getSourceTagType(row.source)" size="small">
{{ row.source }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="downloadStatus" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.downloadStatus)" size="small">
{{ getStatusText(row.downloadStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="下载链接" width="150">
<template #default="{ row }">
<el-button
v-if="row.sourceUrl"
type="primary"
size="small"
link
@click="openLink(row.sourceUrl)"
>
打开链接
</el-button>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<div style="display: flex; flex-direction: column; gap: 5px;">
<el-button-group>
<el-button
size="small"
type="success"
@click="handleMarkComplete(row)"
:disabled="row.downloadStatus === 'COMPLETED'"
>
标记完成
</el-button>
<el-button
size="small"
type="danger"
@click="handleMarkFailed(row)"
:disabled="row.downloadStatus === 'COMPLETED'"
>
标记失败
</el-button>
</el-button-group>
<el-button
size="small"
@click="copyDownloadInfo(row)"
>
复制信息
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<!-- 空状态 -->
<el-empty
v-if="displayTasks.length === 0"
description="暂无下载任务"
style="margin-top: 40px;"
/>
<!-- 批量导出按钮 -->
<div style="margin-top: 20px; text-align: right;">
<el-button
type="primary"
@click="handleExportJson"
:disabled="displayTasks.length === 0"
>
导出为JSON供自动化工具使用
</el-button>
</div>
</el-card>
<!-- 标记完成对话框 -->
<el-dialog v-model="completeDialogVisible" title="标记下载完成" width="500px">
<el-form :model="completeForm" label-width="100px">
<el-form-item label="文件路径">
<el-input
v-model="completeForm.filePath"
placeholder="请输入下载后的文件路径"
clearable
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="completeDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitComplete">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getPendingDownloads, markDownloadComplete, markDownloadFailed } from '@/api/searchResult'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const loading = ref(false)
const tasks = ref([])
const filterStatus = ref('all')
const selectedInquiryId = ref(null)
const completeDialogVisible = ref(false)
const currentTask = ref(null)
const completeForm = ref({
filePath: ''
})
const pendingTasks = computed(() => {
return tasks.value.filter(t => t.downloadStatus === 'PENDING')
})
const downloadingCount = computed(() => {
return tasks.value.filter(t => t.downloadStatus === 'DOWNLOADING').length
})
const completedCount = computed(() => {
return tasks.value.filter(t => t.downloadStatus === 'COMPLETED').length
})
const failedCount = computed(() => {
return tasks.value.filter(t => t.downloadStatus === 'FAILED').length
})
const displayTasks = computed(() => {
let filtered = tasks.value
//
if (filterStatus.value !== 'all') {
filtered = filtered.filter(t => t.downloadStatus === filterStatus.value)
}
//
if (selectedInquiryId.value) {
filtered = filtered.filter(t => t.inquiryRequestId === selectedInquiryId.value)
}
return filtered
})
const uniqueInquiries = computed(() => {
const inquiryMap = new Map()
tasks.value.forEach(task => {
if (!inquiryMap.has(task.inquiryRequestId)) {
inquiryMap.set(task.inquiryRequestId, {
id: task.inquiryRequestId,
requestNumber: `REQ${task.inquiryRequestId}`,
title: task.title?.substring(0, 30) || '未命名'
})
}
})
return Array.from(inquiryMap.values())
})
onMounted(() => {
loadTasks()
})
const loadTasks = async () => {
loading.value = true
try {
const data = await getPendingDownloads()
tasks.value = data || []
} catch (error) {
ElMessage.error('加载下载任务失败')
} finally {
loading.value = false
}
}
const handleRefresh = () => {
loadTasks()
}
const handleMarkComplete = (row) => {
currentTask.value = row
completeForm.value.filePath = row.filePath || ''
completeDialogVisible.value = true
}
const submitComplete = async () => {
if (!completeForm.value.filePath) {
ElMessage.warning('请输入文件路径')
return
}
try {
await markDownloadComplete(currentTask.value.id, completeForm.value.filePath)
ElMessage.success('已标记为完成')
completeDialogVisible.value = false
loadTasks()
} catch (error) {
ElMessage.error('操作失败')
}
}
const handleMarkFailed = (row) => {
ElMessageBox.prompt('请输入失败原因(可选)', '标记失败', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPlaceholder: '失败原因'
}).then(async ({ value }) => {
try {
await markDownloadFailed(row.id, value || '')
ElMessage.success('已标记为失败')
loadTasks()
} catch (error) {
ElMessage.error('操作失败')
}
}).catch(() => {})
}
const openLink = (url) => {
window.open(url, '_blank')
}
const copyDownloadInfo = (row) => {
const info = {
id: row.id,
title: row.title,
source: row.source,
sourceUrl: row.sourceUrl,
doi: row.doi,
pmid: row.pmid,
nctId: row.nctId
}
const text = JSON.stringify(info, null, 2)
navigator.clipboard.writeText(text).then(() => {
ElMessage.success('已复制到剪贴板')
}).catch(() => {
ElMessage.error('复制失败')
})
}
const handleExportJson = () => {
const exportData = displayTasks.value.map(task => ({
id: task.id,
inquiryRequestId: task.inquiryRequestId,
title: task.title,
authors: task.authors,
source: task.source,
sourceUrl: task.sourceUrl,
doi: task.doi,
pmid: task.pmid,
nctId: task.nctId,
downloadStatus: task.downloadStatus,
createdAt: task.createdAt
}))
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `download-tasks-${Date.now()}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
}
const goToInquiry = (inquiryId) => {
router.push(`/inquiry/${inquiryId}`)
}
const formatDateTime = (dateTime) => {
if (!dateTime) return '-'
return new Date(dateTime).toLocaleString('zh-CN')
}
const getStatusTagType = (status) => {
const typeMap = {
'PENDING': 'warning',
'DOWNLOADING': 'primary',
'COMPLETED': 'success',
'FAILED': 'danger',
'NOT_REQUIRED': 'info'
}
return typeMap[status] || 'info'
}
const getStatusText = (status) => {
const textMap = {
'PENDING': '待下载',
'DOWNLOADING': '下载中',
'COMPLETED': '已完成',
'FAILED': '失败',
'NOT_REQUIRED': '不需要'
}
return textMap[status] || status
}
const getSourceTagType = (source) => {
const typeMap = {
'内部数据': 'primary',
'知识库': 'success',
'知网': 'warning',
'ClinicalTrials.gov': 'danger',
'CNKI': 'warning'
}
return typeMap[source] || 'info'
}
const goBack = () => {
router.back()
}
</script>
<style scoped>
.download-tasks {
width: 100%;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.filters {
display: flex;
align-items: center;
margin: 20px 0;
}
.expand-content {
padding: 20px;
}
:deep(.el-statistic__content) {
font-size: 28px;
font-weight: 600;
}
</style>

View File

@ -14,7 +14,14 @@
<el-descriptions-item label="请求编号">
{{ inquiry.requestNumber }}
</el-descriptions-item>
<el-descriptions-item label="客户姓名">
<el-descriptions-item label="客户姓名"> <el-steps :active="currentStep" align-center>
<el-step title="提取关键词" description="使用AI提取查询关键词" />
<el-step title="信息检索" description="检索相关文献和数据" />
<el-step title="生成回复" description="整理信息生成回复" />
<el-step title="审核回复" description="人工审核回复内容" />
<el-step title="下载文献" description="下载相关文献" />
<el-step title="完成" description="处理完成" />
</el-steps>
{{ inquiry.customerName }}
</el-descriptions-item>
<el-descriptions-item label="客户邮箱">
@ -36,16 +43,50 @@
<el-divider />
<el-steps :active="currentStep" align-center>
<el-step title="提取关键词" description="使用AI提取查询关键词" />
<el-step title="信息检索" description="检索相关文献和数据" />
<el-step title="生成回复" description="整理信息生成回复" />
<el-step title="审核回复" description="人工审核回复内容" />
<el-step title="下载文献" description="下载相关文献" />
<el-step title="完成" description="处理完成" />
</el-steps>
<div class="action-section">
<!-- 快捷导航按钮 -->
<el-button
v-if="inquiry.status === 'PENDING'"
type="primary"
@click="goToKeywordConfirmation"
>
开始处理关键词提取
</el-button>
<el-button
v-if="inquiry.status === 'KEYWORD_EXTRACTED' && !inquiry.keywordsConfirmed"
type="primary"
@click="goToKeywordConfirmation"
>
确认关键词
</el-button>
<el-button
v-if="inquiry.keywordsConfirmed && inquiry.status === 'KEYWORD_EXTRACTED'"
type="success"
@click="goToDataSourceSelection"
>
选择数据源并检索
</el-button>
<el-button
v-if="inquiry.status === 'SEARCH_COMPLETED'"
type="warning"
@click="goToSearchResults"
>
查看检索结果
</el-button>
<el-button
v-if="inquiry.responseContent"
type="info"
@click="goToResponseView"
>
查看生成的回复
</el-button>
<!-- AI智能流程按钮 -->
<el-button
v-if="inquiry.status === 'PENDING' || inquiry.status === 'KEYWORD_EXTRACTED'"
@ -622,6 +663,22 @@ const getKeywordField = (keywords, field) => {
return ''
}
}
const goToKeywordConfirmation = () => {
router.push(`/inquiry/${inquiry.value.id}/keyword-confirmation`)
}
const goToDataSourceSelection = () => {
router.push(`/inquiry/${inquiry.value.id}/data-source-selection`)
}
const goToSearchResults = () => {
router.push(`/inquiry/${inquiry.value.id}/search-results`)
}
const goToResponseView = () => {
router.push(`/inquiry/${inquiry.value.id}/response-view`)
}
</script>
<style scoped>

View File

@ -0,0 +1,338 @@
<template>
<div class="keyword-confirmation">
<el-card v-loading="loading">
<template #header>
<div class="card-header">
<span>关键词确认 - {{ inquiry.requestNumber }}</span>
<el-button @click="goBack" text>返回</el-button>
</div>
</template>
<!-- 查询内容展示 -->
<el-alert
title="查询内容"
type="info"
:closable="false"
style="margin-bottom: 20px;"
>
<div style="white-space: pre-wrap; line-height: 1.8;">
{{ inquiry.inquiryContent }}
</div>
</el-alert>
<!-- AI提取结果 -->
<div v-if="!inquiry.keywords" class="extract-section">
<el-empty description="尚未提取关键词">
<el-button type="primary" @click="handleExtract" :loading="extracting">
开始提取关键词
</el-button>
</el-empty>
</div>
<!-- 关键词编辑表单 -->
<div v-else class="keyword-form">
<el-alert
title="AI已自动提取以下关键词请确认或修改"
type="success"
:closable="false"
style="margin-bottom: 20px;"
/>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="药物中文名" prop="drugNameChinese">
<el-input
v-model="form.drugNameChinese"
placeholder="请输入药物中文名称"
clearable
>
<template #prepend>
<el-icon><Pills /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="药物英文名" prop="drugNameEnglish">
<el-input
v-model="form.drugNameEnglish"
placeholder="请输入药物英文名称"
clearable
>
<template #prepend>
<el-icon><Pills /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="查询项目" prop="requestItem">
<el-input
v-model="form.requestItem"
type="textarea"
:rows="3"
placeholder="请输入查询的具体项目或问题"
/>
</el-form-item>
<el-form-item label="额外关键词">
<el-tag
v-for="(tag, index) in form.additionalKeywords"
:key="index"
closable
@close="removeKeyword(index)"
style="margin-right: 10px; margin-bottom: 10px;"
>
{{ tag }}
</el-tag>
<el-input
v-if="inputVisible"
ref="inputRef"
v-model="inputValue"
size="small"
style="width: 150px;"
@keyup.enter="handleInputConfirm"
@blur="handleInputConfirm"
/>
<el-button
v-else
size="small"
@click="showInput"
>
+ 添加关键词
</el-button>
</el-form-item>
<!-- 关键词预览 -->
<el-form-item label="关键词预览">
<div class="keyword-preview">
<el-tag
v-if="form.drugNameChinese"
type="success"
size="large"
style="margin-right: 10px; margin-bottom: 10px;"
>
{{ form.drugNameChinese }}
</el-tag>
<el-tag
v-if="form.drugNameEnglish"
type="primary"
size="large"
style="margin-right: 10px; margin-bottom: 10px;"
>
{{ form.drugNameEnglish }}
</el-tag>
<el-tag
v-if="form.requestItem"
type="warning"
size="large"
style="margin-right: 10px; margin-bottom: 10px;"
>
{{ form.requestItem }}
</el-tag>
<el-tag
v-for="(tag, index) in form.additionalKeywords"
:key="'preview-' + index"
type="info"
size="large"
style="margin-right: 10px; margin-bottom: 10px;"
>
{{ tag }}
</el-tag>
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
@click="handleConfirm"
:loading="confirming"
size="large"
>
确认关键词并继续
</el-button>
<el-button @click="handleReExtract" :loading="extracting">
重新提取
</el-button>
<el-button @click="goBack">取消</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, nextTick, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { getInquiryDetail, extractKeywords, confirmKeywords } from '@/api/inquiry'
import { ElMessage } from 'element-plus'
import { Pills } from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const extracting = ref(false)
const confirming = ref(false)
const inquiry = ref({})
const formRef = ref(null)
const inputRef = ref(null)
const inputVisible = ref(false)
const inputValue = ref('')
const form = reactive({
drugNameChinese: '',
drugNameEnglish: '',
requestItem: '',
additionalKeywords: []
})
const rules = {
drugNameChinese: [
{ required: false, message: '请输入药物中文名称', trigger: 'blur' }
],
drugNameEnglish: [
{ required: false, message: '请输入药物英文名称', trigger: 'blur' }
],
requestItem: [
{ required: false, message: '请输入查询项目', trigger: 'blur' }
]
}
onMounted(() => {
loadDetail()
})
const loadDetail = async () => {
loading.value = true
try {
const id = route.params.id
const data = await getInquiryDetail(id)
inquiry.value = data
//
if (data.keywords) {
parseKeywords(data.keywords)
}
} catch (error) {
ElMessage.error('加载详情失败')
} finally {
loading.value = false
}
}
const parseKeywords = (keywordsStr) => {
try {
const keywords = JSON.parse(keywordsStr)
form.drugNameChinese = keywords.drugNameChinese || ''
form.drugNameEnglish = keywords.drugNameEnglish || ''
form.requestItem = keywords.requestItem || ''
form.additionalKeywords = keywords.additionalKeywords || []
} catch (error) {
console.error('解析关键词失败', error)
}
}
const handleExtract = async () => {
extracting.value = true
try {
await extractKeywords(inquiry.value.id)
ElMessage.success('关键词提取成功')
await loadDetail()
} catch (error) {
ElMessage.error('关键词提取失败: ' + (error.message || '未知错误'))
} finally {
extracting.value = false
}
}
const handleReExtract = async () => {
extracting.value = true
try {
await extractKeywords(inquiry.value.id)
ElMessage.success('关键词重新提取成功')
await loadDetail()
} catch (error) {
ElMessage.error('关键词提取失败')
} finally {
extracting.value = false
}
}
const handleConfirm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
confirming.value = true
try {
await confirmKeywords(inquiry.value.id, form)
ElMessage.success('关键词确认成功')
//
router.push(`/inquiry/${inquiry.value.id}/data-source-selection`)
} catch (error) {
ElMessage.error('确认失败: ' + (error.message || '未知错误'))
} finally {
confirming.value = false
}
}
})
}
const showInput = () => {
inputVisible.value = true
nextTick(() => {
inputRef.value?.focus()
})
}
const handleInputConfirm = () => {
if (inputValue.value && !form.additionalKeywords.includes(inputValue.value)) {
form.additionalKeywords.push(inputValue.value)
}
inputVisible.value = false
inputValue.value = ''
}
const removeKeyword = (index) => {
form.additionalKeywords.splice(index, 1)
}
const goBack = () => {
router.back()
}
</script>
<style scoped>
.keyword-confirmation {
width: 100%;
max-width: 900px;
margin: 0 auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.extract-section {
padding: 40px 0;
}
.keyword-form {
padding: 20px 0;
}
.keyword-preview {
min-height: 50px;
padding: 15px;
background-color: #f5f7fa;
border-radius: 4px;
border: 1px dashed #dcdfe6;
}
:deep(.el-form-item__label) {
font-weight: 600;
}
</style>

View File

@ -0,0 +1,494 @@
<template>
<div class="response-view">
<el-card v-loading="loading">
<template #header>
<div class="card-header">
<span>查看回复 - {{ inquiry.requestNumber }}</span>
<div>
<el-button @click="handlePrint" icon="Printer">打印</el-button>
<el-button @click="handleExport" icon="Download">导出</el-button>
<el-button @click="goBack" text>返回</el-button>
</div>
</div>
</template>
<!-- 查询信息 -->
<el-descriptions :column="2" border style="margin-bottom: 20px;">
<el-descriptions-item label="请求编号">
{{ inquiry.requestNumber }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusType(inquiry.status)">
{{ getStatusText(inquiry.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="客户姓名">
{{ inquiry.customerName || '-' }}
</el-descriptions-item>
<el-descriptions-item label="客户邮箱">
{{ inquiry.customerEmail || '-' }}
</el-descriptions-item>
<el-descriptions-item label="创建时间" :span="2">
{{ formatDateTime(inquiry.createdAt) }}
</el-descriptions-item>
</el-descriptions>
<!-- 回复内容展示区 -->
<div v-if="!responseData" class="empty-response">
<el-empty description="尚未生成回复">
<el-button type="primary" @click="goToSearchResults">
前往管理检索结果
</el-button>
</el-empty>
</div>
<div v-else class="response-content" id="printArea">
<!-- 问题部分 -->
<section class="response-section">
<h2 class="section-title">
<el-icon><QuestionFilled /></el-icon>
查询问题
</h2>
<div class="section-content">
{{ responseData.question || inquiry.inquiryContent }}
</div>
</section>
<!-- 查询到的资料部分 -->
<section class="response-section">
<h2 class="section-title">
<el-icon><Search /></el-icon>
查询到的资料
</h2>
<div class="section-content">
{{ responseData.queriedMaterials }}
</div>
</section>
<!-- 总结部分 -->
<section class="response-section summary-section">
<h2 class="section-title">
<el-icon><Document /></el-icon>
总结
</h2>
<div class="section-content summary-content">
{{ responseData.summary }}
</div>
</section>
<!-- 详细资料列表部分 -->
<section class="response-section">
<h2 class="section-title">
<el-icon><Collection /></el-icon>
详细资料列表
<el-tag type="info" style="margin-left: 10px;">
{{ responseData.materialList?.length || 0 }}
</el-tag>
</h2>
<div class="materials-list">
<el-collapse v-if="responseData.materialList && responseData.materialList.length > 0">
<el-collapse-item
v-for="(material, index) in responseData.materialList"
:key="index"
:name="index"
>
<template #title>
<div class="material-title">
<span class="material-number">{{ index + 1 }}.</span>
<span class="material-name">{{ material.title }}</span>
</div>
</template>
<el-descriptions :column="1" border>
<el-descriptions-item label="作者">
{{ material.authors || 'N/A' }}
</el-descriptions-item>
<el-descriptions-item label="来源">
<el-tag type="primary" size="small">{{ material.source }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="发表日期">
{{ material.publicationDate || 'N/A' }}
</el-descriptions-item>
<el-descriptions-item label="链接" v-if="material.url">
<a :href="material.url" target="_blank" style="color: #409EFF;">
{{ material.url }}
</a>
</el-descriptions-item>
<el-descriptions-item label="摘要">
<div style="white-space: pre-wrap; line-height: 1.8;">
{{ material.summary }}
</div>
</el-descriptions-item>
<el-descriptions-item label="相关性说明">
<div style="white-space: pre-wrap; line-height: 1.8; color: #67C23A;">
{{ material.relevance }}
</div>
</el-descriptions-item>
</el-descriptions>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="暂无资料" />
</div>
</section>
<!-- 生成时间 -->
<div class="response-footer">
<el-text type="info">
生成时间{{ formatDateTime(inquiry.updatedAt) }}
</el-text>
</div>
</div>
<!-- 操作按钮 -->
<el-divider />
<div class="action-buttons" v-if="responseData">
<template v-if="inquiry.status === 'UNDER_REVIEW'">
<el-button
type="success"
size="large"
@click="handleApprove"
:loading="processing"
>
批准回复
</el-button>
<el-button
type="warning"
size="large"
@click="handleReject"
:loading="processing"
>
要求修改
</el-button>
<el-button
size="large"
@click="handleRegenerate"
:loading="processing"
>
重新生成
</el-button>
</template>
<template v-else-if="inquiry.status === 'COMPLETED'">
<el-alert
title="此回复已批准并完成"
type="success"
:closable="false"
show-icon
/>
</template>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { getInquiryDetail, reviewResponse, generateResponse } from '@/api/inquiry'
import { ElMessage, ElMessageBox } from 'element-plus'
import { QuestionFilled, Search, Document, Collection } from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const processing = ref(false)
const inquiry = ref({})
const responseData = ref(null)
onMounted(() => {
loadDetail()
})
const loadDetail = async () => {
loading.value = true
try {
const id = route.params.id
const data = await getInquiryDetail(id)
inquiry.value = data
//
if (data.responseContent) {
try {
responseData.value = JSON.parse(data.responseContent)
} catch (error) {
// JSON
responseData.value = {
question: data.inquiryContent,
queriedMaterials: '已查询相关资料',
summary: data.responseContent,
materialList: []
}
}
}
} catch (error) {
ElMessage.error('加载详情失败')
} finally {
loading.value = false
}
}
const handleApprove = async () => {
ElMessageBox.confirm(
'确认批准此回复?批准后将标记为已完成。',
'批准回复',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'success'
}
).then(async () => {
processing.value = true
try {
await reviewResponse(inquiry.value.id, { approved: true })
ElMessage.success('回复已批准')
loadDetail()
} catch (error) {
ElMessage.error('操作失败')
} finally {
processing.value = false
}
}).catch(() => {})
}
const handleReject = async () => {
ElMessageBox.prompt('请输入修改意见', '要求修改', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputType: 'textarea',
inputPlaceholder: '请说明需要修改的内容'
}).then(async ({ value }) => {
if (!value) {
ElMessage.warning('请输入修改意见')
return
}
processing.value = true
try {
await reviewResponse(inquiry.value.id, { approved: false, comments: value })
ElMessage.success('已提交修改要求')
loadDetail()
} catch (error) {
ElMessage.error('操作失败')
} finally {
processing.value = false
}
}).catch(() => {})
}
const handleRegenerate = async () => {
ElMessageBox.confirm(
'确认重新生成回复?这将覆盖当前的回复内容。',
'重新生成',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
processing.value = true
try {
await generateResponse(inquiry.value.id)
ElMessage.success('回复已重新生成')
loadDetail()
} catch (error) {
ElMessage.error('生成失败: ' + (error.message || '未知错误'))
} finally {
processing.value = false
}
}).catch(() => {})
}
const handlePrint = () => {
window.print()
}
const handleExport = () => {
if (!responseData.value) {
ElMessage.warning('暂无回复内容可导出')
return
}
// JSON
const exportData = {
requestNumber: inquiry.value.requestNumber,
question: responseData.value.question,
queriedMaterials: responseData.value.queriedMaterials,
summary: responseData.value.summary,
materialList: responseData.value.materialList,
generatedAt: inquiry.value.updatedAt
}
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `response-${inquiry.value.requestNumber}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
}
const goToSearchResults = () => {
router.push(`/inquiry/${inquiry.value.id}/search-results`)
}
const formatDateTime = (dateTime) => {
if (!dateTime) return '-'
return new Date(dateTime).toLocaleString('zh-CN')
}
const getStatusType = (status) => {
const typeMap = {
'PENDING': 'info',
'KEYWORD_EXTRACTED': 'warning',
'SEARCHING': 'warning',
'SEARCH_COMPLETED': '',
'UNDER_REVIEW': 'warning',
'DOWNLOADING': 'warning',
'COMPLETED': 'success',
'REJECTED': 'danger'
}
return typeMap[status] || 'info'
}
const getStatusText = (status) => {
const textMap = {
'PENDING': '待处理',
'KEYWORD_EXTRACTED': '已提取关键词',
'SEARCHING': '检索中',
'SEARCH_COMPLETED': '检索完成',
'UNDER_REVIEW': '审核中',
'DOWNLOADING': '下载中',
'COMPLETED': '已完成',
'REJECTED': '已拒绝'
}
return textMap[status] || status
}
const goBack = () => {
router.back()
}
</script>
<style scoped>
.response-view {
width: 100%;
max-width: 1200px;
margin: 0 auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.empty-response {
padding: 60px 0;
}
.response-content {
padding: 20px 0;
}
.response-section {
margin-bottom: 40px;
padding: 20px;
background-color: #f5f7fa;
border-radius: 8px;
border-left: 4px solid #409EFF;
}
.summary-section {
border-left-color: #67C23A;
background: linear-gradient(135deg, #f5f7fa 0%, #e8f4f8 100%);
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 20px;
font-weight: 600;
color: #303133;
margin: 0 0 15px 0;
}
.section-content {
font-size: 16px;
line-height: 2;
color: #606266;
white-space: pre-wrap;
}
.summary-content {
font-size: 17px;
font-weight: 500;
color: #303133;
}
.materials-list {
margin-top: 15px;
}
.material-title {
display: flex;
align-items: center;
width: 100%;
font-size: 16px;
font-weight: 500;
}
.material-number {
margin-right: 10px;
color: #409EFF;
font-weight: 600;
}
.material-name {
flex: 1;
}
.response-footer {
text-align: right;
padding-top: 20px;
margin-top: 20px;
border-top: 1px dashed #dcdfe6;
}
.action-buttons {
text-align: center;
margin-top: 20px;
}
.action-buttons .el-button {
margin: 0 10px;
}
/* 打印样式 */
@media print {
.card-header,
.action-buttons,
.el-divider {
display: none !important;
}
.response-content {
padding: 0;
}
.response-section {
page-break-inside: avoid;
}
}
:deep(.el-collapse-item__header) {
font-size: 16px;
padding: 15px 20px;
}
:deep(.el-collapse-item__content) {
padding: 20px;
}
</style>

View File

@ -0,0 +1,500 @@
<template>
<div class="search-results">
<el-card v-loading="loading">
<template #header>
<div class="card-header">
<span>检索结果管理 - {{ inquiry.requestNumber }}</span>
<div>
<el-button @click="handleRefresh" icon="Refresh" :loading="loading">
刷新
</el-button>
<el-button @click="goBack" text>返回</el-button>
</div>
</div>
</template>
<!-- 统计信息 -->
<el-row :gutter="20" style="margin-bottom: 20px;">
<el-col :span="6">
<el-statistic title="总结果数" :value="results.length">
<template #suffix></template>
</el-statistic>
</el-col>
<el-col :span="6">
<el-statistic title="已纳入回复" :value="includedCount" value-style="color: #67C23A">
<template #suffix></template>
</el-statistic>
</el-col>
<el-col :span="6">
<el-statistic title="需要下载" :value="downloadCount" value-style="color: #E6A23C">
<template #suffix></template>
</el-statistic>
</el-col>
<el-col :span="6">
<el-statistic title="已删除" :value="deletedCount" value-style="color: #F56C6C">
<template #suffix></template>
</el-statistic>
</el-col>
</el-row>
<!-- 批量操作 -->
<div class="batch-actions">
<el-button
type="primary"
@click="handleBatchInclude"
:disabled="selectedResults.length === 0"
>
批量纳入回复
</el-button>
<el-button
type="warning"
@click="handleBatchDownload"
:disabled="selectedResults.length === 0"
>
批量标记下载
</el-button>
<el-button
type="danger"
@click="handleBatchDelete"
:disabled="selectedResults.length === 0"
>
批量删除
</el-button>
<el-button
type="success"
@click="handleGenerateResponse"
:disabled="includedCount === 0"
:loading="generating"
>
生成回复 ({{ includedCount }})
</el-button>
</div>
<!-- 筛选器 -->
<div class="filters">
<el-radio-group v-model="filterType" @change="handleFilterChange">
<el-radio-button label="all">全部 ({{ results.length }})</el-radio-button>
<el-radio-button label="active">未删除 ({{ activeCount }})</el-radio-button>
<el-radio-button label="included">已纳入 ({{ includedCount }})</el-radio-button>
<el-radio-button label="download">需下载 ({{ downloadCount }})</el-radio-button>
<el-radio-button label="deleted">已删除 ({{ deletedCount }})</el-radio-button>
</el-radio-group>
<el-input
v-model="searchText"
placeholder="搜索标题或内容"
clearable
style="width: 300px; margin-left: 15px;"
prefix-icon="Search"
/>
</div>
<!-- 结果列表 -->
<el-table
ref="tableRef"
:data="filteredResults"
@selection-change="handleSelectionChange"
border
stripe
style="margin-top: 20px;"
>
<el-table-column type="selection" width="55" />
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-content">
<el-descriptions :column="2" border>
<el-descriptions-item label="标题" :span="2">
{{ row.title }}
</el-descriptions-item>
<el-descriptions-item label="来源">
<el-tag :type="getSourceTagType(row.source)">{{ row.source }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="发表日期">
{{ row.publicationDate || 'N/A' }}
</el-descriptions-item>
<el-descriptions-item label="作者" :span="2">
{{ row.authors || 'N/A' }}
</el-descriptions-item>
<el-descriptions-item label="摘要" :span="2">
<div style="white-space: pre-wrap; max-height: 200px; overflow-y: auto;">
{{ row.summary || 'N/A' }}
</div>
</el-descriptions-item>
<el-descriptions-item label="DOI" v-if="row.doi">
<a :href="`https://doi.org/${row.doi}`" target="_blank" style="color: #409EFF;">
{{ row.doi }}
</a>
</el-descriptions-item>
<el-descriptions-item label="PMID" v-if="row.pmid">
<a :href="`https://pubmed.ncbi.nlm.nih.gov/${row.pmid}`" target="_blank" style="color: #409EFF;">
{{ row.pmid }}
</a>
</el-descriptions-item>
<el-descriptions-item label="NCT ID" v-if="row.nctId">
<a :href="`https://clinicaltrials.gov/study/${row.nctId}`" target="_blank" style="color: #409EFF;">
{{ row.nctId }}
</a>
</el-descriptions-item>
<el-descriptions-item label="来源链接" v-if="row.sourceUrl" :span="2">
<a :href="row.sourceUrl" target="_blank" style="color: #409EFF;">
{{ row.sourceUrl }}
</a>
</el-descriptions-item>
</el-descriptions>
</div>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="300" show-overflow-tooltip />
<el-table-column prop="source" label="来源" width="150">
<template #default="{ row }">
<el-tag :type="getSourceTagType(row.source)" size="small">
{{ row.source }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="200">
<template #default="{ row }">
<div style="display: flex; flex-direction: column; gap: 5px;">
<el-tag v-if="row.includeInResponse" type="success" size="small">
已纳入回复
</el-tag>
<el-tag v-if="row.needDownload" type="warning" size="small">
需要下载
</el-tag>
<el-tag v-if="row.isDeleted" type="danger" size="small">
已删除
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<div style="display: flex; flex-direction: column; gap: 5px;">
<el-button-group>
<el-button
size="small"
:type="row.includeInResponse ? 'success' : 'default'"
@click="toggleInclude(row)"
:disabled="row.isDeleted"
>
{{ row.includeInResponse ? '已纳入' : '纳入回复' }}
</el-button>
<el-button
size="small"
:type="row.needDownload ? 'warning' : 'default'"
@click="toggleDownload(row)"
:disabled="row.isDeleted"
>
{{ row.needDownload ? '已标记' : '下载全文' }}
</el-button>
</el-button-group>
<el-button
size="small"
type="danger"
@click="handleDelete(row)"
:disabled="row.isDeleted"
>
标记错误并删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<!-- 空状态 -->
<el-empty
v-if="results.length === 0"
description="暂无检索结果"
style="margin-top: 40px;"
>
<el-button type="primary" @click="goBack">返回重新检索</el-button>
</el-empty>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { getInquiryDetail, generateResponse } from '@/api/inquiry'
import {
getSearchResults,
updateSearchResult,
batchUpdateSearchResults,
deleteSearchResult
} from '@/api/searchResult'
import { ElMessage, ElMessageBox } from 'element-plus'
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const generating = ref(false)
const inquiry = ref({})
const results = ref([])
const selectedResults = ref([])
const filterType = ref('active')
const searchText = ref('')
const tableRef = ref(null)
const includedCount = computed(() => {
return results.value.filter(r => r.includeInResponse && !r.isDeleted).length
})
const downloadCount = computed(() => {
return results.value.filter(r => r.needDownload && !r.isDeleted).length
})
const deletedCount = computed(() => {
return results.value.filter(r => r.isDeleted).length
})
const activeCount = computed(() => {
return results.value.filter(r => !r.isDeleted).length
})
const filteredResults = computed(() => {
let filtered = results.value
//
switch (filterType.value) {
case 'active':
filtered = filtered.filter(r => !r.isDeleted)
break
case 'included':
filtered = filtered.filter(r => r.includeInResponse && !r.isDeleted)
break
case 'download':
filtered = filtered.filter(r => r.needDownload && !r.isDeleted)
break
case 'deleted':
filtered = filtered.filter(r => r.isDeleted)
break
}
//
if (searchText.value) {
const text = searchText.value.toLowerCase()
filtered = filtered.filter(r =>
r.title?.toLowerCase().includes(text) ||
r.summary?.toLowerCase().includes(text) ||
r.content?.toLowerCase().includes(text)
)
}
return filtered
})
onMounted(() => {
loadDetail()
loadResults()
})
const loadDetail = async () => {
try {
const id = route.params.id
const data = await getInquiryDetail(id)
inquiry.value = data
} catch (error) {
ElMessage.error('加载详情失败')
}
}
const loadResults = async () => {
loading.value = true
try {
const id = route.params.id
const data = await getSearchResults(id)
results.value = data || []
} catch (error) {
ElMessage.error('加载检索结果失败')
} finally {
loading.value = false
}
}
const handleRefresh = () => {
loadResults()
}
const handleSelectionChange = (selection) => {
selectedResults.value = selection
}
const toggleInclude = async (row) => {
try {
const newValue = !row.includeInResponse
await updateSearchResult(row.id, { includeInResponse: newValue })
row.includeInResponse = newValue
ElMessage.success(newValue ? '已纳入回复' : '已取消纳入')
} catch (error) {
ElMessage.error('操作失败')
}
}
const toggleDownload = async (row) => {
try {
const newValue = !row.needDownload
await updateSearchResult(row.id, { needDownload: newValue })
row.needDownload = newValue
ElMessage.success(newValue ? '已标记下载' : '已取消下载')
} catch (error) {
ElMessage.error('操作失败')
}
}
const handleDelete = async (row) => {
ElMessageBox.confirm(
'确认将此条结果标记为错误并删除?',
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
await deleteSearchResult(row.id)
row.isDeleted = true
ElMessage.success('已删除')
} catch (error) {
ElMessage.error('删除失败')
}
}).catch(() => {})
}
const handleBatchInclude = async () => {
try {
const updates = selectedResults.value.map(r => ({
id: r.id,
includeInResponse: true
}))
await batchUpdateSearchResults(updates)
selectedResults.value.forEach(r => {
r.includeInResponse = true
})
ElMessage.success(`已将 ${updates.length} 条结果纳入回复`)
tableRef.value?.clearSelection()
} catch (error) {
ElMessage.error('批量操作失败')
}
}
const handleBatchDownload = async () => {
try {
const updates = selectedResults.value.map(r => ({
id: r.id,
needDownload: true
}))
await batchUpdateSearchResults(updates)
selectedResults.value.forEach(r => {
r.needDownload = true
})
ElMessage.success(`已将 ${updates.length} 条结果标记下载`)
tableRef.value?.clearSelection()
} catch (error) {
ElMessage.error('批量操作失败')
}
}
const handleBatchDelete = async () => {
ElMessageBox.confirm(
`确认删除选中的 ${selectedResults.value.length} 条结果?`,
'批量删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
for (const row of selectedResults.value) {
await deleteSearchResult(row.id)
row.isDeleted = true
}
ElMessage.success('批量删除成功')
tableRef.value?.clearSelection()
} catch (error) {
ElMessage.error('批量删除失败')
}
}).catch(() => {})
}
const handleGenerateResponse = async () => {
ElMessageBox.confirm(
`将基于 ${includedCount.value} 条已纳入的资料生成回复,确认继续?`,
'生成回复',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}
).then(async () => {
generating.value = true
try {
await generateResponse(inquiry.value.id)
ElMessage.success('回复生成成功,正在跳转...')
router.push(`/inquiry/${inquiry.value.id}/response-view`)
} catch (error) {
ElMessage.error('生成回复失败: ' + (error.message || '未知错误'))
} finally {
generating.value = false
}
}).catch(() => {})
}
const handleFilterChange = () => {
tableRef.value?.clearSelection()
}
const getSourceTagType = (source) => {
const typeMap = {
'内部数据': 'primary',
'知识库': 'success',
'知网': 'warning',
'ClinicalTrials.gov': 'danger',
'CNKI': 'warning'
}
return typeMap[source] || 'info'
}
const goBack = () => {
router.push(`/inquiry/${inquiry.value.id}`)
}
</script>
<style scoped>
.search-results {
width: 100%;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.batch-actions {
margin: 20px 0;
}
.batch-actions .el-button {
margin-right: 10px;
}
.filters {
display: flex;
align-items: center;
margin: 20px 0;
}
.expand-content {
padding: 20px;
}
:deep(.el-statistic__content) {
font-size: 28px;
font-weight: 600;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,152 +0,0 @@
# 系统截图文件夹
本文件夹用于存放医学信息支持系统的界面截图,供演示文稿使用。
## 建议的截图清单
### 核心功能截图
1. **login.png** - 登录页面
- 展示系统登录界面
2. **dashboard.png** - 工作台首页
- 展示系统主界面和数据统计
3. **inquiry-list.png** - 查询列表页面
- 展示查询请求列表
4. **inquiry-create.png** - 创建查询页面
- 展示如何创建新查询
5. **inquiry-detail.png** - 查询详情页面
- 展示查询处理流程
6. **drug-list.png** - 药物列表页面
- 展示药物信息列表
7. **drug-detail.png** - 药物详情页面
- 展示药物详细信息和安全性数据
8. **clinical-trials.png** - 临床试验页面
- 展示临床试验搜索和结果
9. **knowledge-base.png** - 知识库管理页面
- 展示知识库配置
10. **system-config.png** - 系统配置页面
- 展示系统设置界面
## 截图要求
### 技术要求
- **格式**PNG推荐或 JPG
- **分辨率**:建议 1920x1080 或更高
- **文件大小**:每张不超过 2MB
### 内容要求
- 使用**测试数据**或**脱敏数据**,不要包含真实敏感信息
- 界面要**完整清晰**,避免截图不全
- 确保**中文显示正常**,没有乱码
- 尽量展示**有数据的状态**,避免空白页面
### 美观建议
- 关闭不必要的浏览器工具栏和书签栏
- 使用**全屏模式**截图F11
- 确保界面**布局整齐**,没有错位
- 选择**有代表性的数据**进行展示
## 如何在演示文稿中使用截图
### 方法一:直接嵌入
编辑 `presentation.html`,在需要的位置添加:
```html
<section>
<h2>系统界面展示</h2>
<img src="screenshots/dashboard.png" class="screenshot" alt="工作台界面">
<p>系统工作台提供一站式的信息概览</p>
</section>
```
### 方法二:创建对比展示
```html
<section>
<h2>操作流程</h2>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div>
<img src="screenshots/inquiry-create.png" class="screenshot">
<p>步骤1创建查询</p>
</div>
<div>
<img src="screenshots/inquiry-detail.png" class="screenshot">
<p>步骤2处理查询</p>
</div>
</div>
</section>
```
### 方法三:全屏展示
```html
<section data-background-image="screenshots/dashboard.png" data-background-size="contain">
<div style="background: rgba(0,0,0,0.7); padding: 20px; color: white;">
<h2>工作台界面</h2>
<p>清晰的数据展示,一目了然</p>
</div>
</section>
```
## 快速截图工具推荐
### Windows
- **Snipping Tool** (系统自带)
- **Snagit** (专业工具)
- **ShareX** (免费开源)
### Mac
- **Command + Shift + 4** (系统快捷键)
- **Command + Shift + 5** (截屏工具)
### 浏览器扩展
- **Awesome Screenshot**
- **Nimbus Screenshot**
- **Full Page Screen Capture**
## 截图技巧
### 1. 准备测试数据
在截图前,确保系统中有:
- 几条示例查询记录
- 药物信息(如达菲林)
- 临床试验搜索结果
- 知识库配置示例
### 2. 统一浏览器设置
- 缩放比例100%
- 窗口大小最大化或1920x1080
- 主题:浅色(更适合演示)
### 3. 批量截图流程
1. 登录系统
2. 按照功能模块依次截图
3. 立即重命名保存(避免混淆)
4. 检查所有截图质量
5. 选择最佳的添加到演示文稿
## 注意事项
⚠️ **隐私保护**
- 不要截取包含真实患者信息的页面
- 不要截取包含真实医生信息的页面
- 使用测试账号进行截图
- 模糊处理任何敏感信息
⚠️ **版权说明**
- 系统界面截图仅供内部演示使用
- 对外发布前需获得授权
---
**截图完成后,删除本说明文件即可。**