Refactor inquiry and RPA task management in frontend; add RPA task creation and retrieval functions, enhance inquiry detail view with CNKI results handling, and update database schema for RPA tasks.
This commit is contained in:
parent
29d0950c6f
commit
769a7518da
|
|
@ -0,0 +1,432 @@
|
|||
# RPA 对接约定(CNKI/万方检索)
|
||||
|
||||
本文档用于终端 RPA(影刀)配置参考,说明任务拉取、状态回传与结果上报协议。当前后端已提供完整 REST API,可直接通过 HTTP 调用。
|
||||
|
||||
## 1. 基本信息
|
||||
- 基础地址(Base URL):根据部署而定,例如 `http://localhost:8080/api`
|
||||
- 注意:后端配置了 context-path `/api`,所有接口路径需要加上此前缀
|
||||
- 鉴权:当前环境默认放开 CORS,无鉴权头;生产建议加网关或 Token 认证
|
||||
- 编码:UTF-8,JSON 请求与响应
|
||||
|
||||
## 2. 名词与对象
|
||||
- Inquiry(查询请求):一次客户问题受理流程的载体
|
||||
- RpaTask(RPA 任务):面向某一数据源(CNKI/WANFANG)的具体检索任务,1 条检索式 = 1 个任务
|
||||
- SearchResultItem(检索结果项):RPA 回传的单条文献信息,归属某个 Inquiry
|
||||
|
||||
## 3. 任务流转状态
|
||||
- PENDING → RUNNING → COMPLETED | FAILED
|
||||
- 失败可被重新拉取并重试(见 4.4)
|
||||
|
||||
## 4. API 说明
|
||||
|
||||
### 4.1 为某查询创建 RPA 任务(用于前端/调试)
|
||||
POST `/api/rpa-tasks/inquiry/{inquiryId}/create`
|
||||
|
||||
Request Body:
|
||||
```json
|
||||
{
|
||||
"source": "CNKI",
|
||||
"queryExpressions": [
|
||||
"阿莫西林 AND 不良反应",
|
||||
"Amoxicillin adverse events"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Response(200):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Success",
|
||||
"data": [
|
||||
{
|
||||
"id": 123,
|
||||
"inquiryId": 88,
|
||||
"source": "CNKI",
|
||||
"status": "PENDING",
|
||||
"queryExpression": "阿莫西林 AND 不良反应",
|
||||
"remark": null,
|
||||
"createdAt": "2025-10-30T12:00:00",
|
||||
"startedAt": null,
|
||||
"finishedAt": null
|
||||
}
|
||||
],
|
||||
"timestamp": 1727683200000
|
||||
}
|
||||
```
|
||||
|
||||
错误响应(400):
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "数据源不能为空",
|
||||
"timestamp": 1727683200000
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 获取所有RPA任务(用于管理页面)
|
||||
GET `/api/rpa-tasks`
|
||||
|
||||
说明:返回所有RPA任务,按创建时间倒序排列。
|
||||
|
||||
Response(200):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": 123,
|
||||
"inquiryId": 88,
|
||||
"source": "CNKI",
|
||||
"status": "PENDING",
|
||||
"queryExpression": "阿莫西林 AND 不良反应",
|
||||
"createdAt": "2025-10-30T12:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 获取指定查询的所有RPA任务
|
||||
GET `/api/rpa-tasks/inquiry/{inquiryId}`
|
||||
|
||||
说明:返回指定查询的所有RPA任务,按ID升序排列。
|
||||
|
||||
Response(200):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": 123,
|
||||
"inquiryId": 88,
|
||||
"source": "CNKI",
|
||||
"status": "PENDING",
|
||||
"queryExpression": "阿莫西林 AND 不良反应",
|
||||
"createdAt": "2025-10-30T12:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 RPA 轮询获取待执行任务
|
||||
GET `/api/rpa-tasks/pending?source=CNKI`
|
||||
|
||||
说明:返回 `PENDING` 与 `FAILED` 的任务(失败可重试)。RPA 应按顺序逐个领取。
|
||||
|
||||
参数:
|
||||
- `source`(必填):数据源,值为 `CNKI` 或 `WANFANG`(不区分大小写,自动转换)
|
||||
|
||||
Response(200):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": 123,
|
||||
"inquiryId": 88,
|
||||
"source": "CNKI",
|
||||
"status": "PENDING",
|
||||
"queryExpression": "阿莫西林 AND 不良反应",
|
||||
"createdAt": "2025-10-30T12:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 RPA 标记任务开始
|
||||
POST `/api/rpa-tasks/{taskId}/start`
|
||||
|
||||
Response(200):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 123,
|
||||
"status": "RUNNING",
|
||||
"startedAt": "2025-10-30T12:01:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.6 RPA 回传检索结果
|
||||
POST `/api/rpa-tasks/{taskId}/results`
|
||||
|
||||
Request Body:数组,单项结构如下(尽可能填充,字段均为可选):
|
||||
```json
|
||||
[
|
||||
{
|
||||
"title": "文献题目",
|
||||
"authors": "作者A; 作者B",
|
||||
"source": "CNKI",
|
||||
"sourceUrl": "https://...",
|
||||
"publicationDate": "2023-09-01",
|
||||
"summary": "摘要...",
|
||||
"content": "全文内容(可选)",
|
||||
"doi": "10.xxxx/xxx(可选)",
|
||||
"pmid": "12345678(可选)",
|
||||
"metadata": "{}(可选,后端会注入rpaTaskId和queryExpression)"
|
||||
}
|
||||
]
|
||||
```
|
||||
说明:
|
||||
- 后端会自动补全 `inquiryRequestId`(根据任务关联的查询ID)
|
||||
- 如果 `source` 为空,会自动设置为任务的数据源
|
||||
- 后端会在 `metadata` 中注入 `{ rpaTaskId, queryExpression }` 以便前端按任务分组展示
|
||||
- 如果 `metadata` 已存在,会保留原有内容
|
||||
|
||||
Response(200):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": 999,
|
||||
"inquiryRequestId": 88,
|
||||
"title": "文献题目",
|
||||
"authors": "作者A; 作者B",
|
||||
"source": "CNKI",
|
||||
"sourceUrl": "https://...",
|
||||
"publicationDate": "2023-09-01",
|
||||
"summary": "摘要...",
|
||||
"metadata": "{\"rpaTaskId\":123,\"queryExpression\":\"阿莫西林 AND 不良反应\"}",
|
||||
"status": "ACTIVE",
|
||||
"includeInResponse": false,
|
||||
"needDownload": false,
|
||||
"createdAt": "2025-10-30T12:02:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
任务成功回传后,任务状态将置为 `COMPLETED`。
|
||||
|
||||
### 4.7 RPA 回传结果(CNKI字符串格式)
|
||||
POST `/api/rpa-tasks/{taskId}/results/text`
|
||||
|
||||
说明:接收CNKI引用格式的字符串,后端自动解析为检索结果项。适用于影刀RPA机器人返回的CNKI引用格式数据。
|
||||
|
||||
Request Body(字符串格式):
|
||||
```
|
||||
[1]王冰冰. 注射用醋酸曲普瑞林序贯地诺孕素治疗卵巢子宫内膜异位症的临床效果[J].临床合理用药,2025,18(30):125-127.DOI:10.15887/j.cnki.13-1389/r.2025.30.034.
|
||||
[2]黄婉茹,黄小盼,林少勇. 醋酸曲普瑞林联合重组人生长激素治疗大骨龄特发性中枢性性早熟女童的效果观察[J].大医生,2025,10(19):62-64.
|
||||
```
|
||||
|
||||
格式说明:
|
||||
- `[序号]`:文献序号
|
||||
- `作者.`:作者信息(以.结尾)
|
||||
- `标题[J].`:文章标题和文献类型[J](期刊)
|
||||
- `期刊名,`:期刊名称(以,结尾)
|
||||
- `年份,`:发表年份(以,结尾)
|
||||
- `卷(期):页码`:卷号、期号和页码(如18(30):125-127)
|
||||
- `.DOI:xxx`:DOI号(可选,以.DOI:开头)
|
||||
|
||||
特殊处理:
|
||||
- 如果 `rawResult` 为空或null,表示未检索到文献,任务仍会标记为完成(状态:COMPLETED),但返回空结果列表
|
||||
- 后端会自动解析字符串,提取以下字段:
|
||||
- `title`:文章题目
|
||||
- `authors`:作者
|
||||
- `summary`:期刊名称
|
||||
- `publicationDate`:发表年份
|
||||
- `content`:卷期页码信息(格式:卷:18, 期:30, 页码:125-127)
|
||||
- `doi`:DOI号
|
||||
- `sourceUrl`:DOI链接(如果有DOI,自动构造 https://dx.doi.org/{doi})
|
||||
- `source`:自动设置为"CNKI"
|
||||
|
||||
Response(200):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": 999,
|
||||
"inquiryRequestId": 88,
|
||||
"title": "注射用醋酸曲普瑞林序贯地诺孕素治疗卵巢子宫内膜异位症的临床效果",
|
||||
"authors": "王冰冰",
|
||||
"source": "CNKI",
|
||||
"summary": "临床合理用药",
|
||||
"publicationDate": "2025",
|
||||
"content": "卷:18, 期:30, 页码:125-127",
|
||||
"doi": "10.15887/j.cnki.13-1389/r.2025.30.034",
|
||||
"sourceUrl": "https://dx.doi.org/10.15887/j.cnki.13-1389/r.2025.30.034",
|
||||
"metadata": "{\"rpaTaskId\":123,\"queryExpression\":\"阿莫西林 AND 不良反应\"}"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.8 RPA 标记任务失败(可重试)
|
||||
POST `/api/rpa-tasks/{taskId}/fail?reason=网络错误`
|
||||
|
||||
参数:
|
||||
- `reason`(可选):失败原因说明
|
||||
|
||||
Response(200):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": 123,
|
||||
"inquiryId": 88,
|
||||
"source": "CNKI",
|
||||
"status": "FAILED",
|
||||
"queryExpression": "阿莫西林 AND 不良反应",
|
||||
"remark": "网络错误",
|
||||
"createdAt": "2025-10-30T12:00:00",
|
||||
"startedAt": "2025-10-30T12:01:00",
|
||||
"finishedAt": "2025-10-30T12:05:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 参数验证与错误处理
|
||||
|
||||
### 5.1 创建任务参数验证
|
||||
- `inquiryId`:必须为有效数字,且对应的查询请求存在
|
||||
- `source`:必须为 `CNKI` 或 `WANFANG`(不区分大小写,自动转换为大写)
|
||||
- `queryExpressions`:必须为非空数组,每个检索式不能为空字符串
|
||||
- 空的检索式会被自动跳过,但至少需要有一个有效检索式才能创建任务
|
||||
|
||||
### 5.2 错误响应格式
|
||||
所有接口在发生错误时会返回统一的错误响应:
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "错误描述信息",
|
||||
"timestamp": 1727683200000
|
||||
}
|
||||
```
|
||||
|
||||
常见HTTP状态码:
|
||||
- `400 Bad Request`:参数验证失败(如数据源为空、检索式列表为空等)
|
||||
- `500 Internal Server Error`:服务器内部错误(如数据库连接失败等)
|
||||
|
||||
### 5.3 异常处理说明
|
||||
- `IllegalArgumentException`:参数验证失败,返回 400 状态码
|
||||
- `RuntimeException`:业务逻辑错误(如任务不存在),返回 500 状态码
|
||||
|
||||
## 6. 建议的轮询与执行策略
|
||||
- 轮询频率:每 60 秒调用一次 4.4 接口(获取待处理任务);若无任务可适当退避
|
||||
- 领取策略:
|
||||
1) 调用 4.4 拉取待处理任务列表;2) 取第一条 `taskId` 调用 4.5 标记开始;3) 执行检索;
|
||||
4) 根据返回数据格式选择接口:
|
||||
- 如果返回JSON格式数组,调用 4.6 接口(标准格式)
|
||||
- 如果返回CNKI引用格式字符串,调用 4.7 接口(字符串格式,自动解析)
|
||||
5) 异常则调用 4.8 标记失败(可重试)
|
||||
- 并发建议:同一来源每台终端控制在 1~2 并发,避免目标站封禁
|
||||
|
||||
## 7. 字段说明(摘要)
|
||||
|
||||
### 7.1 RpaTaskDTO 字段
|
||||
- `id`:任务ID(Long)
|
||||
- `inquiryId`:关联的查询请求ID(Long)
|
||||
- `source`:数据源,值为 `CNKI` 或 `WANFANG`(String)
|
||||
- `status`:任务状态,值为 `PENDING`、`RUNNING`、`COMPLETED`、`FAILED`(String)
|
||||
- `queryExpression`:检索表达式(String,最大长度2000)
|
||||
- `remark`:备注信息,如失败原因(String,最大长度1024,可选)
|
||||
- `createdAt`:创建时间(LocalDateTime)
|
||||
- `startedAt`:开始执行时间(LocalDateTime,可选)
|
||||
- `finishedAt`:完成时间(LocalDateTime,可选)
|
||||
|
||||
### 7.2 SearchResultItemDTO 回传字段(主要字段)
|
||||
- `title`:文献标题(String,可选)
|
||||
- `authors`:作者(String,可选)
|
||||
- `source`:数据源(String,如果为空会使用任务的数据源)
|
||||
- `sourceUrl`:源链接(String,可选)
|
||||
- `publicationDate`:发表日期(String,可选)
|
||||
- `summary`:摘要(String,可选)
|
||||
- `content`:全文内容(String,可选)
|
||||
- `doi`:DOI号(String,可选)
|
||||
- `pmid`:PMID号(String,可选)
|
||||
- `nctId`:NCT ID(String,可选)
|
||||
- `metadata`:元数据JSON字符串(String,可选,后端会注入rpaTaskId和queryExpression)
|
||||
|
||||
注意:后端会自动设置以下字段:
|
||||
- `inquiryRequestId`:根据任务关联的查询ID自动设置
|
||||
- `source`:如果为空,使用任务的数据源
|
||||
- `metadata`:如果为空,会注入 `{"rpaTaskId":xxx,"queryExpression":"xxx"}`;如果已有内容,会保留
|
||||
|
||||
## 8. 示例(curl)
|
||||
|
||||
获取所有任务:
|
||||
```bash
|
||||
curl -X GET "http://localhost:8080/api/rpa-tasks"
|
||||
```
|
||||
|
||||
获取指定查询的任务:
|
||||
```bash
|
||||
curl -X GET "http://localhost:8080/api/rpa-tasks/inquiry/88"
|
||||
```
|
||||
|
||||
获取待处理任务:
|
||||
```bash
|
||||
curl -X GET "http://localhost:8080/api/rpa-tasks/pending?source=CNKI"
|
||||
```
|
||||
|
||||
创建任务:
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/rpa-tasks/inquiry/88/create" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"source": "CNKI",
|
||||
"queryExpressions": ["阿莫西林 AND 不良反应", "Amoxicillin adverse events"]
|
||||
}'
|
||||
```
|
||||
|
||||
标记开始:
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/rpa-tasks/123/start"
|
||||
```
|
||||
|
||||
回传结果:
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/rpa-tasks/123/results" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[
|
||||
{
|
||||
"title": "文献题目",
|
||||
"authors": "作者A; 作者B",
|
||||
"source": "CNKI",
|
||||
"sourceUrl": "https://example.com",
|
||||
"publicationDate": "2023-09-01",
|
||||
"summary": "摘要..."
|
||||
}
|
||||
]'
|
||||
```
|
||||
|
||||
标记失败:
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/rpa-tasks/123/fail?reason=网络错误"
|
||||
```
|
||||
|
||||
回传结果(CNKI字符串格式):
|
||||
```bash
|
||||
curl -X POST "http://localhost:8080/api/rpa-tasks/123/results/text" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '[1]王冰冰. 注射用醋酸曲普瑞林序贯地诺孕素治疗卵巢子宫内膜异位症的临床效果[J].临床合理用药,2025,18(30):125-127.DOI:10.15887/j.cnki.13-1389/r.2025.30.034.'
|
||||
```
|
||||
|
||||
注意:字符串格式接口需要传递纯文本字符串,不是JSON格式。
|
||||
|
||||
## 9. 前端展示要点(供联调参考)
|
||||
- 查询详情页"知网"Tab 按 `{ rpaTaskId, queryExpression }` 分组展示;RPA 回传后点击"刷新"即可看到新结果
|
||||
- 前端会从 `metadata` 字段解析 `rpaTaskId` 和 `queryExpression` 来分组显示结果
|
||||
|
||||
## 10. 常见问题
|
||||
|
||||
### 10.1 任务重复执行
|
||||
若上一次失败,任务会出现在待处理列表(状态为 `FAILED`),可再次执行并回传。
|
||||
|
||||
### 10.2 去重处理
|
||||
如需服务器去重,可在回传时于 `metadata` 提供文献唯一标识,后续可在服务端增加去重逻辑。
|
||||
|
||||
### 10.3 数据源大小写
|
||||
`source` 参数不区分大小写,后端会自动转换为大写(`CNKI` 或 `WANFANG`)。
|
||||
|
||||
### 10.4 空检索式处理
|
||||
创建任务时,空的检索式会被自动跳过。但至少需要有一个有效检索式才能创建任务。
|
||||
|
||||
### 10.5 任务状态说明
|
||||
- `PENDING`:待处理,等待RPA终端拉取执行
|
||||
- `RUNNING`:执行中,RPA终端已标记开始
|
||||
- `COMPLETED`:已完成,结果已成功回传
|
||||
- `FAILED`:执行失败,可重新拉取并重试
|
||||
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
package com.ipsen.medical.controller;
|
||||
|
||||
import com.ipsen.medical.dto.ApiResponse;
|
||||
import com.ipsen.medical.dto.RpaTaskDTO;
|
||||
import com.ipsen.medical.dto.SearchResultItemDTO;
|
||||
import com.ipsen.medical.service.RpaTaskService;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/rpa-tasks")
|
||||
@RequiredArgsConstructor
|
||||
@CrossOrigin(origins = "*")
|
||||
public class RpaTaskController {
|
||||
|
||||
private final RpaTaskService rpaTaskService;
|
||||
|
||||
@Data
|
||||
public static class CreateTasksRequest {
|
||||
private String source; // CNKI/WANFANG
|
||||
private List<String> queryExpressions; // 多条检索式
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定查询创建 RPA 任务
|
||||
*/
|
||||
@PostMapping("/inquiry/{inquiryId}/create")
|
||||
public ResponseEntity<ApiResponse<List<RpaTaskDTO>>> createTasks(
|
||||
@PathVariable Long inquiryId,
|
||||
@RequestBody CreateTasksRequest req) {
|
||||
try {
|
||||
// 参数验证
|
||||
if (req == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("请求体不能为空"));
|
||||
}
|
||||
if (req.getSource() == null || req.getSource().trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("数据源不能为空"));
|
||||
}
|
||||
if (req.getQueryExpressions() == null || req.getQueryExpressions().isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error("检索式列表不能为空"));
|
||||
}
|
||||
|
||||
List<RpaTaskDTO> tasks = rpaTaskService.createTasks(inquiryId, req.getSource(), req.getQueryExpressions());
|
||||
return ResponseEntity.ok(ApiResponse.success(tasks));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(ApiResponse.error(e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("创建RPA任务失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有RPA任务(用于管理页面)
|
||||
*/
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<List<RpaTaskDTO>>> getAllTasks() {
|
||||
List<RpaTaskDTO> tasks = rpaTaskService.getAllTasks();
|
||||
return ResponseEntity.ok(ApiResponse.success(tasks));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定查询的所有RPA任务
|
||||
*/
|
||||
@GetMapping("/inquiry/{inquiryId}")
|
||||
public ResponseEntity<ApiResponse<List<RpaTaskDTO>>> getInquiryTasks(@PathVariable Long inquiryId) {
|
||||
List<RpaTaskDTO> tasks = rpaTaskService.getInquiryTasks(inquiryId);
|
||||
return ResponseEntity.ok(ApiResponse.success(tasks));
|
||||
}
|
||||
|
||||
/**
|
||||
* RPA 轮询:获取待处理任务(含失败可重试)
|
||||
*/
|
||||
@GetMapping("/pending")
|
||||
public ResponseEntity<ApiResponse<List<RpaTaskDTO>>> getPending(@RequestParam String source) {
|
||||
List<RpaTaskDTO> tasks = rpaTaskService.getPendingTasks(source);
|
||||
return ResponseEntity.ok(ApiResponse.success(tasks));
|
||||
}
|
||||
|
||||
/**
|
||||
* RPA 标记开始
|
||||
*/
|
||||
@PostMapping("/{taskId}/start")
|
||||
public ResponseEntity<ApiResponse<RpaTaskDTO>> markStart(@PathVariable Long taskId) {
|
||||
RpaTaskDTO dto = rpaTaskService.markStarted(taskId);
|
||||
return ResponseEntity.ok(ApiResponse.success(dto));
|
||||
}
|
||||
|
||||
/**
|
||||
* RPA 回传结果(标准格式)
|
||||
*/
|
||||
@PostMapping("/{taskId}/results")
|
||||
public ResponseEntity<ApiResponse<List<SearchResultItemDTO>>> submitResults(
|
||||
@PathVariable Long taskId,
|
||||
@RequestBody List<SearchResultItemDTO> results) {
|
||||
try {
|
||||
List<SearchResultItemDTO> created = rpaTaskService.submitResults(taskId, results);
|
||||
return ResponseEntity.ok(ApiResponse.success(created));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("回传结果失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RPA 回传结果(CNKI字符串格式)
|
||||
* 接收CNKI引用格式的字符串,自动解析为检索结果项
|
||||
*/
|
||||
@PostMapping("/{taskId}/results/text")
|
||||
public ResponseEntity<ApiResponse<List<SearchResultItemDTO>>> submitResultsAsText(
|
||||
@PathVariable Long taskId,
|
||||
@RequestBody String rawResult) {
|
||||
try {
|
||||
// 如果结果为空或null,表示没有检索到文献
|
||||
if (rawResult == null || rawResult.trim().isEmpty()) {
|
||||
// 标记任务完成,但没有结果
|
||||
rpaTaskService.markCompleted(taskId);
|
||||
return ResponseEntity.ok(ApiResponse.success(new ArrayList<>()));
|
||||
}
|
||||
|
||||
List<SearchResultItemDTO> created = rpaTaskService.submitResultsAsText(taskId, rawResult);
|
||||
return ResponseEntity.ok(ApiResponse.success(created));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500)
|
||||
.body(ApiResponse.error("回传结果失败: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RPA 标记失败
|
||||
*/
|
||||
@PostMapping("/{taskId}/fail")
|
||||
public ResponseEntity<ApiResponse<RpaTaskDTO>> markFail(
|
||||
@PathVariable Long taskId,
|
||||
@RequestParam(required = false) String reason) {
|
||||
RpaTaskDTO dto = rpaTaskService.markFailed(taskId, reason);
|
||||
return ResponseEntity.ok(ApiResponse.success(dto));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -54,3 +54,5 @@ public class ClinicalTrialDTO {
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -33,3 +33,5 @@ public class ClinicalTrialsSearchResult {
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
package com.ipsen.medical.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class RpaTaskDTO {
|
||||
private Long id;
|
||||
private Long inquiryId;
|
||||
private String source; // CNKI, WANFANG
|
||||
private String status; // PENDING, RUNNING, COMPLETED, FAILED
|
||||
private String queryExpression;
|
||||
private String remark;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime startedAt;
|
||||
private LocalDateTime finishedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
package com.ipsen.medical.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "rpa_task")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class RpaTask {
|
||||
|
||||
public enum TaskSource {
|
||||
CNKI,
|
||||
WANFANG
|
||||
}
|
||||
|
||||
public enum TaskStatus {
|
||||
PENDING,
|
||||
RUNNING,
|
||||
COMPLETED,
|
||||
FAILED
|
||||
}
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long inquiryId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
private TaskSource source;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
private TaskStatus status = TaskStatus.PENDING;
|
||||
|
||||
@Column(nullable = false, length = 2000)
|
||||
private String queryExpression;
|
||||
|
||||
@Column(length = 1024)
|
||||
private String remark;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDateTime createdAt = LocalDateTime.now();
|
||||
|
||||
private LocalDateTime startedAt;
|
||||
|
||||
private LocalDateTime finishedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -43,3 +43,5 @@ public interface ClinicalTrialRepository extends JpaRepository<ClinicalTrial, Lo
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
package com.ipsen.medical.repository;
|
||||
|
||||
import com.ipsen.medical.entity.RpaTask;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface RpaTaskRepository extends JpaRepository<RpaTask, Long> {
|
||||
|
||||
List<RpaTask> findByInquiryIdOrderByIdAsc(Long inquiryId);
|
||||
|
||||
List<RpaTask> findBySourceAndStatusOrderByIdAsc(RpaTask.TaskSource source, RpaTask.TaskStatus status);
|
||||
|
||||
@Query("select t from RpaTask t where t.status in ('PENDING','FAILED') and t.source = ?1 order by t.id asc")
|
||||
List<RpaTask> findPendingOrFailed(RpaTask.TaskSource source);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -56,3 +56,5 @@ public interface ClinicalTrialsService {
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
package com.ipsen.medical.service;
|
||||
|
||||
import com.ipsen.medical.dto.SearchResultItemDTO;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* CNKI引用格式解析器
|
||||
* 解析格式:[序号]作者. 标题[J].期刊名,年份,卷(期):页码.DOI:xxx
|
||||
* 示例:[1]王冰冰. 注射用醋酸曲普瑞林序贯地诺孕素治疗卵巢子宫内膜异位症的临床效果[J].临床合理用药,2025,18(30):125-127.DOI:10.15887/j.cnki.13-1389/r.2025.30.034.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class CnkiResultParser {
|
||||
|
||||
// 匹配完整的引用格式:[序号]作者. 标题[J].期刊名,年份,卷(期):页码.DOI:xxx
|
||||
private static final Pattern CNKI_PATTERN = Pattern.compile(
|
||||
"\\[\\d+\\]" + // [序号]
|
||||
"([^.]+\\.)" + // 作者部分(到第一个.为止)
|
||||
"\\s*([^\\[\\]]+)" + // 标题部分(到[J]之前)
|
||||
"\\[J\\]\\." + // [J].
|
||||
"([^,]+)" + // 期刊名(到,之前)
|
||||
",(\\d{4})" + // 年份
|
||||
",(\\d+)\\((\\d+)\\)" + // 卷(期)
|
||||
":([^.]+\\.?)" + // 页码(到DOI之前或末尾)
|
||||
"(?:\\.DOI:([^\\s\\.]+))?" // DOI(可选,以.DOI:开头)
|
||||
);
|
||||
|
||||
/**
|
||||
* 解析CNKI引用格式字符串为SearchResultItemDTO列表
|
||||
*
|
||||
* @param rawResult 原始结果字符串,可能包含多条记录,以换行或分号分隔
|
||||
* @return 解析后的结果列表
|
||||
*/
|
||||
public List<SearchResultItemDTO> parse(String rawResult) {
|
||||
List<SearchResultItemDTO> results = new ArrayList<>();
|
||||
|
||||
if (rawResult == null || rawResult.trim().isEmpty()) {
|
||||
log.info("CNKI结果为空,返回空列表");
|
||||
return results;
|
||||
}
|
||||
|
||||
// 按行分割,每行可能是一条记录
|
||||
String[] lines = rawResult.split("\n");
|
||||
|
||||
for (String line : lines) {
|
||||
line = line.trim();
|
||||
if (line.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 尝试解析单行
|
||||
List<SearchResultItemDTO> parsed = parseLine(line);
|
||||
results.addAll(parsed);
|
||||
}
|
||||
|
||||
log.info("CNKI结果解析完成,共解析出 {} 条记录", results.size());
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析单行引用格式
|
||||
* 格式:[序号]作者. 标题[J].期刊名,年份,卷(期):页码.DOI:xxx
|
||||
*/
|
||||
private List<SearchResultItemDTO> parseLine(String line) {
|
||||
List<SearchResultItemDTO> results = new ArrayList<>();
|
||||
|
||||
// 首先尝试完整格式匹配
|
||||
Matcher matcher = CNKI_PATTERN.matcher(line);
|
||||
|
||||
// 查找所有匹配项(一行可能有多个引用)
|
||||
while (matcher.find()) {
|
||||
try {
|
||||
SearchResultItemDTO dto = new SearchResultItemDTO();
|
||||
|
||||
// 提取各字段
|
||||
String authors = matcher.group(1).trim();
|
||||
if (authors.endsWith(".")) {
|
||||
authors = authors.substring(0, authors.length() - 1).trim();
|
||||
}
|
||||
dto.setAuthors(authors);
|
||||
|
||||
String title = matcher.group(2).trim();
|
||||
dto.setTitle(title);
|
||||
|
||||
String journal = matcher.group(3).trim();
|
||||
// 将期刊信息存储在summary字段,格式:期刊名
|
||||
dto.setSummary(journal);
|
||||
|
||||
String year = matcher.group(4);
|
||||
String volume = matcher.group(5);
|
||||
String issue = matcher.group(6);
|
||||
String pages = matcher.group(7).trim();
|
||||
// 清理页码末尾的点
|
||||
if (pages.endsWith(".")) {
|
||||
pages = pages.substring(0, pages.length() - 1).trim();
|
||||
}
|
||||
|
||||
// 组合发表日期:年份
|
||||
dto.setPublicationDate(year);
|
||||
|
||||
// 卷期页码信息存储在content字段中,格式:卷:18, 期:30, 页码:125-127
|
||||
dto.setContent(String.format("卷:%s, 期:%s, 页码:%s", volume, issue, pages));
|
||||
|
||||
// DOI(第8组)
|
||||
if (matcher.groupCount() >= 8 && matcher.group(8) != null && !matcher.group(8).trim().isEmpty()) {
|
||||
String doi = matcher.group(8).trim();
|
||||
// 清理DOI末尾的点
|
||||
if (doi.endsWith(".")) {
|
||||
doi = doi.substring(0, doi.length() - 1).trim();
|
||||
}
|
||||
dto.setDoi(doi);
|
||||
// 如果有DOI,构造DOI链接
|
||||
dto.setSourceUrl("https://dx.doi.org/" + doi);
|
||||
} else {
|
||||
// 如果没有DOI,尝试从原始行中查找
|
||||
String doiPattern = "\\.DOI:([^\\s\\.]+)";
|
||||
Pattern doiRegex = Pattern.compile(doiPattern);
|
||||
Matcher doiMatcher = doiRegex.matcher(line);
|
||||
if (doiMatcher.find()) {
|
||||
String doi = doiMatcher.group(1).trim();
|
||||
if (doi.endsWith(".")) {
|
||||
doi = doi.substring(0, doi.length() - 1).trim();
|
||||
}
|
||||
dto.setDoi(doi);
|
||||
dto.setSourceUrl("https://dx.doi.org/" + doi);
|
||||
}
|
||||
}
|
||||
|
||||
dto.setSource("CNKI");
|
||||
|
||||
results.add(dto);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("解析CNKI引用格式失败,跳过该匹配: {}", line, e);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有匹配到任何格式,尝试简单解析
|
||||
if (results.isEmpty()) {
|
||||
log.warn("未能解析CNKI引用格式,尝试简单解析: {}", line);
|
||||
// 简单解析:尝试提取基本字段
|
||||
String cleanLine = line.replaceFirst("^\\[\\d+\\]", "").trim();
|
||||
String[] parts = cleanLine.split("\\.", 3);
|
||||
|
||||
if (parts.length >= 2) {
|
||||
SearchResultItemDTO dto = new SearchResultItemDTO();
|
||||
dto.setAuthors(parts[0].trim());
|
||||
dto.setTitle(parts[1].trim());
|
||||
if (parts.length >= 3) {
|
||||
dto.setSummary(parts[2].trim());
|
||||
}
|
||||
dto.setSource("CNKI");
|
||||
// 存储原始内容以供后续处理
|
||||
dto.setContent(line);
|
||||
results.add(dto);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.ipsen.medical.service;
|
||||
|
||||
import com.ipsen.medical.dto.RpaTaskDTO;
|
||||
import com.ipsen.medical.dto.SearchResultItemDTO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface RpaTaskService {
|
||||
List<RpaTaskDTO> createTasks(Long inquiryId, String source, List<String> queryExpressions);
|
||||
|
||||
List<RpaTaskDTO> getInquiryTasks(Long inquiryId);
|
||||
|
||||
List<RpaTaskDTO> getAllTasks();
|
||||
|
||||
List<RpaTaskDTO> getPendingTasks(String source);
|
||||
|
||||
RpaTaskDTO markStarted(Long taskId);
|
||||
|
||||
RpaTaskDTO markFailed(Long taskId, String reason);
|
||||
|
||||
RpaTaskDTO markCompleted(Long taskId);
|
||||
|
||||
List<SearchResultItemDTO> submitResults(Long taskId, List<SearchResultItemDTO> results);
|
||||
|
||||
/**
|
||||
* 提交CNKI字符串格式的结果,自动解析
|
||||
*/
|
||||
List<SearchResultItemDTO> submitResultsAsText(Long taskId, String rawResult);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -171,7 +171,7 @@ public class InquiryServiceImpl implements InquiryService {
|
|||
InquiryRequest inquiryRequest = inquiryRequestRepository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("查询请求不存在: " + id));
|
||||
// 仅在已检索状态生成回复
|
||||
assertStatus(inquiryRequest, InquiryRequest.RequestStatus.SEARCHED);
|
||||
assertStatus(inquiryRequest, InquiryRequest.RequestStatus.SEARCHED, InquiryRequest.RequestStatus.SEARCHING);
|
||||
|
||||
try {
|
||||
// 获取用户选中的检索结果(includeInResponse = true)
|
||||
|
|
@ -322,7 +322,7 @@ public class InquiryServiceImpl implements InquiryService {
|
|||
public InquiryRequestDTO generateDownloadTasks(Long id, List<Long> searchResultItemIds) {
|
||||
InquiryRequest inquiryRequest = inquiryRequestRepository.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("查询请求不存在: " + id));
|
||||
assertStatus(inquiryRequest, InquiryRequest.RequestStatus.SEARCHED);
|
||||
assertStatus(inquiryRequest, InquiryRequest.RequestStatus.SEARCHED, InquiryRequest.RequestStatus.SEARCHING);
|
||||
|
||||
if (searchResultItemIds == null || searchResultItemIds.isEmpty()) {
|
||||
throw new RuntimeException("未选中任何需要下载的条目");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,233 @@
|
|||
package com.ipsen.medical.service.impl;
|
||||
|
||||
import com.ipsen.medical.dto.RpaTaskDTO;
|
||||
import com.ipsen.medical.dto.SearchResultItemDTO;
|
||||
import com.ipsen.medical.entity.RpaTask;
|
||||
import com.ipsen.medical.repository.RpaTaskRepository;
|
||||
import com.ipsen.medical.service.CnkiResultParser;
|
||||
import com.ipsen.medical.service.RpaTaskService;
|
||||
import com.ipsen.medical.service.SearchResultService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional
|
||||
public class RpaTaskServiceImpl implements RpaTaskService {
|
||||
|
||||
private final RpaTaskRepository rpaTaskRepository;
|
||||
private final SearchResultService searchResultService;
|
||||
private final CnkiResultParser cnkiResultParser;
|
||||
|
||||
@Override
|
||||
public List<RpaTaskDTO> createTasks(Long inquiryId, String source, List<String> queryExpressions) {
|
||||
// 参数验证
|
||||
if (inquiryId == null) {
|
||||
throw new IllegalArgumentException("查询ID不能为空");
|
||||
}
|
||||
if (source == null || source.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("数据源不能为空");
|
||||
}
|
||||
if (queryExpressions == null || queryExpressions.isEmpty()) {
|
||||
throw new IllegalArgumentException("检索式列表不能为空");
|
||||
}
|
||||
|
||||
// 验证 source 是否为有效的枚举值
|
||||
RpaTask.TaskSource taskSource;
|
||||
try {
|
||||
taskSource = RpaTask.TaskSource.valueOf(source.toUpperCase().trim());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IllegalArgumentException("不支持的数据源: " + source + ",支持的数据源: CNKI, WANFANG", e);
|
||||
}
|
||||
|
||||
List<RpaTask> tasks = new ArrayList<>();
|
||||
for (String expr : queryExpressions) {
|
||||
if (expr == null || expr.trim().isEmpty()) {
|
||||
log.warn("跳过空的检索式");
|
||||
continue;
|
||||
}
|
||||
RpaTask task = new RpaTask();
|
||||
task.setInquiryId(inquiryId);
|
||||
task.setSource(taskSource);
|
||||
task.setStatus(RpaTask.TaskStatus.PENDING);
|
||||
task.setQueryExpression(expr.trim());
|
||||
tasks.add(rpaTaskRepository.save(task));
|
||||
log.info("创建RPA任务: inquiryId={}, source={}, queryExpression={}", inquiryId, source, expr);
|
||||
}
|
||||
|
||||
if (tasks.isEmpty()) {
|
||||
throw new IllegalArgumentException("没有有效的检索式可以创建任务");
|
||||
}
|
||||
|
||||
return tasks.stream().map(this::toDTO).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RpaTaskDTO> getInquiryTasks(Long inquiryId) {
|
||||
return rpaTaskRepository.findByInquiryIdOrderByIdAsc(inquiryId)
|
||||
.stream().map(this::toDTO).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RpaTaskDTO> getAllTasks() {
|
||||
return rpaTaskRepository.findAll().stream()
|
||||
.map(this::toDTO)
|
||||
.sorted((a, b) -> {
|
||||
// 按创建时间倒序
|
||||
if (a.getCreatedAt() == null && b.getCreatedAt() == null) return 0;
|
||||
if (a.getCreatedAt() == null) return 1;
|
||||
if (b.getCreatedAt() == null) return -1;
|
||||
return b.getCreatedAt().compareTo(a.getCreatedAt());
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RpaTaskDTO> getPendingTasks(String source) {
|
||||
if (source == null || source.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("数据源不能为空");
|
||||
}
|
||||
RpaTask.TaskSource taskSource;
|
||||
try {
|
||||
taskSource = RpaTask.TaskSource.valueOf(source.toUpperCase().trim());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IllegalArgumentException("不支持的数据源: " + source + ",支持的数据源: CNKI, WANFANG", e);
|
||||
}
|
||||
return rpaTaskRepository.findPendingOrFailed(taskSource)
|
||||
.stream().map(this::toDTO).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public RpaTaskDTO markStarted(Long taskId) {
|
||||
RpaTask task = rpaTaskRepository.findById(taskId)
|
||||
.orElseThrow(() -> new RuntimeException("RPA任务不存在: " + taskId));
|
||||
task.setStatus(RpaTask.TaskStatus.RUNNING);
|
||||
task.setStartedAt(LocalDateTime.now());
|
||||
return toDTO(rpaTaskRepository.save(task));
|
||||
}
|
||||
|
||||
@Override
|
||||
public RpaTaskDTO markFailed(Long taskId, String reason) {
|
||||
RpaTask task = rpaTaskRepository.findById(taskId)
|
||||
.orElseThrow(() -> new RuntimeException("RPA任务不存在: " + taskId));
|
||||
task.setStatus(RpaTask.TaskStatus.FAILED);
|
||||
task.setRemark(reason);
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
return toDTO(rpaTaskRepository.save(task));
|
||||
}
|
||||
|
||||
@Override
|
||||
public RpaTaskDTO markCompleted(Long taskId) {
|
||||
RpaTask task = rpaTaskRepository.findById(taskId)
|
||||
.orElseThrow(() -> new RuntimeException("RPA任务不存在: " + taskId));
|
||||
task.setStatus(RpaTask.TaskStatus.COMPLETED);
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
return toDTO(rpaTaskRepository.save(task));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SearchResultItemDTO> submitResults(Long taskId, List<SearchResultItemDTO> results) {
|
||||
RpaTask task = rpaTaskRepository.findById(taskId)
|
||||
.orElseThrow(() -> new RuntimeException("RPA任务不存在: " + taskId));
|
||||
|
||||
// 补充来源与 inquiry 绑定
|
||||
List<SearchResultItemDTO> toCreate = results.stream().map(dto -> {
|
||||
dto.setInquiryRequestId(task.getInquiryId());
|
||||
if (dto.getSource() == null || dto.getSource().isEmpty()) {
|
||||
dto.setSource(task.getSource().name());
|
||||
}
|
||||
// 注入分组元数据,便于前端按任务分Tab展示
|
||||
try {
|
||||
String meta = dto.getMetadata();
|
||||
String inject = "{\"rpaTaskId\":" + task.getId() + ",\"queryExpression\":\"" +
|
||||
task.getQueryExpression().replace("\"", "\\\"") + "\"}";
|
||||
if (meta == null || meta.trim().isEmpty()) {
|
||||
dto.setMetadata(inject);
|
||||
} else {
|
||||
// 简单拼接为一个对象数组字符串,避免破坏原有格式
|
||||
dto.setMetadata(meta);
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
return dto;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
List<SearchResultItemDTO> created = searchResultService.batchCreateSearchResults(toCreate);
|
||||
|
||||
task.setStatus(RpaTask.TaskStatus.COMPLETED);
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
rpaTaskRepository.save(task);
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SearchResultItemDTO> submitResultsAsText(Long taskId, String rawResult) {
|
||||
RpaTask task = rpaTaskRepository.findById(taskId)
|
||||
.orElseThrow(() -> new RuntimeException("RPA任务不存在: " + taskId));
|
||||
|
||||
// 使用解析器解析CNKI引用格式字符串
|
||||
List<SearchResultItemDTO> parsedResults = cnkiResultParser.parse(rawResult);
|
||||
|
||||
if (parsedResults.isEmpty()) {
|
||||
log.warn("解析CNKI结果为空,任务ID: {}", taskId);
|
||||
// 仍然标记为完成,但没有结果
|
||||
markCompleted(taskId);
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// 补充来源与 inquiry 绑定
|
||||
List<SearchResultItemDTO> toCreate = parsedResults.stream().map(dto -> {
|
||||
dto.setInquiryRequestId(task.getInquiryId());
|
||||
if (dto.getSource() == null || dto.getSource().isEmpty()) {
|
||||
dto.setSource(task.getSource().name());
|
||||
}
|
||||
// 注入分组元数据,便于前端按任务分Tab展示
|
||||
try {
|
||||
String inject = "{\"rpaTaskId\":" + task.getId() + ",\"queryExpression\":\"" +
|
||||
task.getQueryExpression().replace("\"", "\\\"") + "\"}";
|
||||
if (dto.getMetadata() == null || dto.getMetadata().trim().isEmpty()) {
|
||||
dto.setMetadata(inject);
|
||||
} else {
|
||||
// 保留原有metadata
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("注入metadata失败", e);
|
||||
}
|
||||
return dto;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
List<SearchResultItemDTO> created = searchResultService.batchCreateSearchResults(toCreate);
|
||||
|
||||
// 标记任务完成
|
||||
task.setStatus(RpaTask.TaskStatus.COMPLETED);
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
rpaTaskRepository.save(task);
|
||||
|
||||
log.info("CNKI结果解析并保存完成,任务ID: {}, 解析出 {} 条记录", taskId, created.size());
|
||||
return created;
|
||||
}
|
||||
|
||||
private RpaTaskDTO toDTO(RpaTask task) {
|
||||
RpaTaskDTO dto = new RpaTaskDTO();
|
||||
dto.setId(task.getId());
|
||||
dto.setInquiryId(task.getInquiryId());
|
||||
dto.setSource(task.getSource().name());
|
||||
dto.setStatus(task.getStatus().name());
|
||||
dto.setQueryExpression(task.getQueryExpression());
|
||||
dto.setRemark(task.getRemark());
|
||||
dto.setCreatedAt(task.getCreatedAt());
|
||||
dto.setStartedAt(task.getStartedAt());
|
||||
dto.setFinishedAt(task.getFinishedAt());
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
USE medical_info_system;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rpa_task (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
inquiry_id BIGINT NOT NULL COMMENT '关联的查询请求ID',
|
||||
source VARCHAR(32) NOT NULL COMMENT '数据源: CNKI, WANFANG',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态: PENDING, RUNNING, COMPLETED, FAILED',
|
||||
query_expression VARCHAR(2000) NOT NULL COMMENT '检索表达式',
|
||||
remark VARCHAR(1024) COMMENT '备注信息(如失败原因)',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
started_at DATETIME COMMENT '开始执行时间',
|
||||
finished_at DATETIME COMMENT '完成时间',
|
||||
INDEX idx_inquiry_id (inquiry_id),
|
||||
INDEX idx_source (source),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_source_status (source, status),
|
||||
FOREIGN KEY (inquiry_id) REFERENCES inquiry_requests(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='RPA自动化检索任务表';
|
||||
|
|
@ -219,3 +219,5 @@ SELECT '药物模块初始化完成!' AS message,
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -190,6 +190,26 @@ CREATE TABLE IF NOT EXISTS clinical_trials (
|
|||
FOREIGN KEY (inquiry_id) REFERENCES inquiry_requests(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- RPA 任务表
|
||||
CREATE TABLE IF NOT EXISTS rpa_task (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
inquiry_id BIGINT NOT NULL COMMENT '关联的查询请求ID',
|
||||
source VARCHAR(32) NOT NULL COMMENT '数据源: CNKI, WANFANG',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态: PENDING, RUNNING, COMPLETED, FAILED',
|
||||
query_expression VARCHAR(2000) NOT NULL COMMENT '检索表达式',
|
||||
remark VARCHAR(1024) COMMENT '备注信息(如失败原因)',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
started_at DATETIME COMMENT '开始执行时间',
|
||||
finished_at DATETIME COMMENT '完成时间',
|
||||
INDEX idx_inquiry_id (inquiry_id),
|
||||
INDEX idx_source (source),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_source_status (source, status),
|
||||
FOREIGN KEY (inquiry_id) REFERENCES inquiry_requests(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='RPA自动化检索任务表';
|
||||
|
||||
-- 插入默认管理员用户 (密码: admin123, 实际使用时应该加密)
|
||||
INSERT INTO users (username, password, full_name, email, role, enabled, created_at)
|
||||
VALUES ('admin', '$2a$10$rK8WpQYJzxJ8Y5X5YqFvRO5K7K5K7K5K7K5K7K5K7K5K7K5K7K5K7',
|
||||
|
|
|
|||
|
|
@ -194,6 +194,37 @@ export function generateDownloadTasks(id, ids) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 RPA 任务(CNKI / WANFANG)
|
||||
*/
|
||||
export function createRpaTasks(inquiryId, source, queryExpressions) {
|
||||
return request({
|
||||
url: `/rpa-tasks/inquiry/${inquiryId}/create`,
|
||||
method: 'post',
|
||||
data: { source, queryExpressions }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有 RPA 任务
|
||||
*/
|
||||
export function getAllRpaTasks() {
|
||||
return request({
|
||||
url: '/rpa-tasks',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定查询的所有 RPA 任务
|
||||
*/
|
||||
export function getInquiryRpaTasks(inquiryId) {
|
||||
return request({
|
||||
url: `/rpa-tasks/inquiry/${inquiryId}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -73,12 +73,26 @@ const routes = [
|
|||
component: () => import('@/views/inquiry/ResponseView.vue'),
|
||||
meta: { title: '回复查看', icon: 'Document' },
|
||||
hidden: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/automation',
|
||||
component: Layout,
|
||||
redirect: '/automation/rpa-tasks',
|
||||
meta: { title: '自动执行', icon: 'Operation' },
|
||||
children: [
|
||||
{
|
||||
path: 'rpa-tasks',
|
||||
name: 'RpaTasks',
|
||||
component: () => import('@/views/inquiry/RpaTasks.vue'),
|
||||
meta: { title: '检索任务', icon: 'Robot' }
|
||||
},
|
||||
{
|
||||
path: 'download-tasks',
|
||||
name: 'DownloadTasks',
|
||||
component: () => import('@/views/inquiry/DownloadTasks.vue'),
|
||||
meta: { title: '待下载任务', icon: 'Download' }
|
||||
meta: { title: '下载任务', icon: 'Download' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -206,16 +206,6 @@
|
|||
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>
|
||||
|
||||
<!-- 标记完成对话框 -->
|
||||
|
|
@ -382,30 +372,6 @@ const copyDownloadInfo = (row) => {
|
|||
})
|
||||
}
|
||||
|
||||
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}`)
|
||||
|
|
|
|||
|
|
@ -272,7 +272,66 @@
|
|||
<el-empty description="暂无数据或未实现" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="知网" name="CNKI">
|
||||
<el-empty description="暂无数据或未实现" />
|
||||
<div class="result-section">
|
||||
<div class="section-header">
|
||||
<h3>知网(CNKI)检索结果</h3>
|
||||
<div class="section-actions">
|
||||
<el-button type="primary" @click="handleAddCnkiToDownload" icon="Plus">加入下载</el-button>
|
||||
<el-button @click="loadCnkiResults" icon="Refresh">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-tabs v-if="cnkiGroups.length > 0" v-model="activeCnkiTab">
|
||||
<el-tab-pane
|
||||
v-for="(group, idx) in cnkiGroups"
|
||||
:key="`cnki-${idx}`"
|
||||
:label="group.label"
|
||||
:name="group.name"
|
||||
>
|
||||
<el-table
|
||||
:data="group.data"
|
||||
border
|
||||
style="width: 100%; margin-top: 12px;"
|
||||
@selection-change="(sel) => onCnkiSelectionChange(group.name, sel)"
|
||||
>
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column prop="title" label="文章题目" min-width="350" show-overflow-tooltip />
|
||||
<el-table-column prop="authors" label="作者" width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="summary" label="期刊名称" width="180" show-overflow-tooltip />
|
||||
<el-table-column label="卷刊号" width="120">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.content && row.content.includes('卷:')">
|
||||
{{ extractVolumeIssue(row.content) }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="页码" width="120">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.content && row.content.includes('页码:')">
|
||||
{{ extractPages(row.content) }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="publicationDate" label="年份" width="80" />
|
||||
<el-table-column prop="doi" label="DOI" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.doi">{{ row.doi }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="链接" width="100">
|
||||
<template #default="{ row }">
|
||||
<a v-if="row.sourceUrl" :href="row.sourceUrl" target="_blank" style="color:#409EFF">查看</a>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="group.data.length === 0" description="暂无数据" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<el-empty v-else description="暂无数据" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
|
|
@ -297,8 +356,11 @@ import { useRouter, useRoute } from 'vue-router'
|
|||
getClinicalTrials,
|
||||
exportClinicalTrials,
|
||||
performAutoSearch,
|
||||
executeFullWorkflow
|
||||
executeFullWorkflow,
|
||||
createRpaTasks,
|
||||
generateDownloadTasks
|
||||
} from '@/api/inquiry'
|
||||
import { getSearchResults } from '@/api/searchResult'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
|
|
@ -314,10 +376,20 @@ const clinicalTrials = ref([])
|
|||
const clinicalTrialsGroups = ref([])
|
||||
const searchingClinicalTrials = ref(false)
|
||||
|
||||
// 知网多检索式结果分组
|
||||
const cnkiGroups = ref([])
|
||||
// 每个知网结果分组的选择项
|
||||
const cnkiSelections = ref({})
|
||||
const currentCnkiSelection = computed(() => {
|
||||
const key = activeCnkiTab.value
|
||||
return (cnkiSelections.value && key && cnkiSelections.value[key]) ? cnkiSelections.value[key] : []
|
||||
})
|
||||
|
||||
// 新增:标准检索词与检索范围、多Tab
|
||||
const standardQuery = ref('')
|
||||
const selectedSources = ref(['CLINICAL_TRIALS']) // 仍用于结果Tab默认行为
|
||||
const activeResultsTab = ref('CLINICAL_TRIALS')
|
||||
const activeCnkiTab = ref('CNKI_1')
|
||||
|
||||
// 新:详情页内的范围选择控制(独立于 selectedSources 的展示用途)
|
||||
const selectedScopes = ref([])
|
||||
|
|
@ -372,6 +444,7 @@ const hasAnyResults = computed(() => {
|
|||
onMounted(() => {
|
||||
loadDetail()
|
||||
loadClinicalTrials()
|
||||
loadCnkiResults()
|
||||
})
|
||||
|
||||
const loadDetail = async () => {
|
||||
|
|
@ -438,14 +511,30 @@ const loadClinicalTrials = async () => {
|
|||
try {
|
||||
const id = route.params.id
|
||||
clinicalTrials.value = await getClinicalTrials(id)
|
||||
// 将后端保存的单次结果映射为一个默认分组,便于统一渲染
|
||||
clinicalTrialsGroups.value = [
|
||||
{
|
||||
name: 'CLINICAL_TRIALS',
|
||||
label: 'ClinicalTrials',
|
||||
data: clinicalTrials.value || []
|
||||
// 如果已有分组数据,保留现有分组;否则创建默认分组
|
||||
if (clinicalTrialsGroups.value.length === 0) {
|
||||
clinicalTrialsGroups.value = [
|
||||
{
|
||||
name: 'CLINICAL_TRIALS',
|
||||
label: 'ClinicalTrials',
|
||||
originalQuery: '',
|
||||
data: clinicalTrials.value || []
|
||||
}
|
||||
]
|
||||
} else {
|
||||
// 如果已有分组,尝试更新默认分组的数据(如果存在)
|
||||
const defaultGroup = clinicalTrialsGroups.value.find(g => g.name === 'CLINICAL_TRIALS')
|
||||
if (defaultGroup && clinicalTrials.value && clinicalTrials.value.length > 0) {
|
||||
// 合并数据,避免重复
|
||||
const existingNctIds = new Set(defaultGroup.data.map(item => item.nctId))
|
||||
clinicalTrials.value.forEach(item => {
|
||||
if (!existingNctIds.has(item.nctId)) {
|
||||
defaultGroup.data.push(item)
|
||||
existingNctIds.add(item.nctId)
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载临床试验数据失败', error)
|
||||
}
|
||||
|
|
@ -467,21 +556,91 @@ const handleExtractKeywords = async () => {
|
|||
}
|
||||
|
||||
const handleSearchWithParams = async () => {
|
||||
const keyword = (standardQuery.value || '').trim()
|
||||
if (!keyword) {
|
||||
ElMessage.warning('请先填写标准检索词')
|
||||
// 验证查询ID
|
||||
if (!inquiry.value || !inquiry.value.id) {
|
||||
ElMessage.error('查询ID不存在,请刷新页面后重试')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否有被勾选的检索范围
|
||||
if (!selectedScopes.value || selectedScopes.value.length === 0) {
|
||||
ElMessage.warning('请至少选择一个检索范围')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查每个勾选的检索范围是否有检索式
|
||||
let hasValidQuery = false
|
||||
for (const scope of selectedScopes.value) {
|
||||
const queries = (scopeQueries[scope] || [])
|
||||
.map(q => (q.text || '').trim())
|
||||
.filter(Boolean)
|
||||
if (queries.length > 0) {
|
||||
hasValidQuery = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasValidQuery) {
|
||||
ElMessage.warning('请至少在一个勾选的检索范围中填写检索式')
|
||||
return
|
||||
}
|
||||
|
||||
searchLoading.value = true
|
||||
try {
|
||||
// 仅当选择了 ClinicalTrials 时,执行临床试验检索
|
||||
if (selectedSources.value.includes('CLINICAL_TRIALS')) {
|
||||
await handleSearchClinicalTrials()
|
||||
const executedScopes = []
|
||||
|
||||
// 仅对被勾选且有检索式的检索范围执行检索
|
||||
if (selectedScopes.value.includes('CLINICAL_TRIALS')) {
|
||||
const ctQueries = (scopeQueries.CLINICAL_TRIALS || [])
|
||||
.map(q => (q.text || '').trim())
|
||||
.filter(Boolean)
|
||||
if (ctQueries.length > 0) {
|
||||
await handleSearchClinicalTrials()
|
||||
executedScopes.push('CLINICAL_TRIALS')
|
||||
}
|
||||
}
|
||||
|
||||
// 当选择了 CNKI 且有检索式时,创建 RPA 任务(每条检索式一条任务)
|
||||
if (selectedScopes.value.includes('CNKI')) {
|
||||
const cnkiQueries = (scopeQueries.CNKI || [])
|
||||
.map(q => (q.text || '').trim())
|
||||
.filter(Boolean)
|
||||
if (cnkiQueries.length > 0) {
|
||||
try {
|
||||
await createRpaTasks(inquiry.value.id, 'CNKI', cnkiQueries)
|
||||
ElMessage.success('已创建知网RPA任务,等待终端执行并回传')
|
||||
// 触发一次拉取现有结果(RPA回传后可手动点击刷新)
|
||||
await loadCnkiResults()
|
||||
executedScopes.push('CNKI')
|
||||
} catch (error) {
|
||||
console.error('创建CNKI RPA任务失败:', error)
|
||||
const errorMsg = error.response?.data?.message || error.message || '创建RPA任务失败'
|
||||
ElMessage.error('创建知网RPA任务失败: ' + errorMsg)
|
||||
throw error // 重新抛出,让外层知道有错误发生
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 处理其他检索范围(INTERNAL, KNOWLEDGE_BASE)
|
||||
// if (selectedScopes.value.includes('INTERNAL')) { ... }
|
||||
// if (selectedScopes.value.includes('KNOWLEDGE_BASE')) { ... }
|
||||
|
||||
if (executedScopes.length === 0) {
|
||||
ElMessage.warning('没有可执行的检索,请检查检索范围和检索式')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success('检索完成')
|
||||
activeResultsTab.value = 'CLINICAL_TRIALS'
|
||||
// 切换到第一个执行的检索范围的结果tab
|
||||
if (executedScopes.includes('CNKI')) {
|
||||
activeResultsTab.value = 'CNKI'
|
||||
} else if (executedScopes.includes('CLINICAL_TRIALS')) {
|
||||
activeResultsTab.value = clinicalTrialsGroups.value[0]?.name || 'CLINICAL_TRIALS'
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('检索失败')
|
||||
console.error('检索失败:', error)
|
||||
const errorMsg = error.response?.data?.message || error.message || '未知错误'
|
||||
ElMessage.error('检索失败: ' + errorMsg)
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
|
|
@ -630,50 +789,74 @@ const sanitizeForClinicalTrials = (q) => {
|
|||
}
|
||||
|
||||
const handleSearchClinicalTrials = async () => {
|
||||
// 取被选中的 ClinicalTrials 范围内的所有检索式(最多两条按需求展示)
|
||||
// 取被选中的 ClinicalTrials 范围内的所有检索式
|
||||
const queries = (scopeQueries.CLINICAL_TRIALS || [])
|
||||
.map(q => (q.text || '').trim())
|
||||
.filter(Boolean)
|
||||
|
||||
// 若未设置多条,则回退到标准检索词
|
||||
if (queries.length === 0) {
|
||||
const fallback = (standardQuery.value || '').trim()
|
||||
if (fallback) queries.push(fallback)
|
||||
}
|
||||
|
||||
if (queries.length === 0) {
|
||||
ElMessage.warning('请先填写标准检索词')
|
||||
ElMessage.warning('请先填写临床试验检索式')
|
||||
return
|
||||
}
|
||||
|
||||
searchingClinicalTrials.value = true
|
||||
try {
|
||||
clinicalTrialsGroups.value = []
|
||||
// 保留历史结果,不清空 existingGroups
|
||||
// 使用 Map 来跟踪已存在的分组(基于原始检索式文本)
|
||||
const existingGroupsMap = new Map()
|
||||
clinicalTrialsGroups.value.forEach(group => {
|
||||
// 使用 originalQuery 作为唯一标识,因为它是原始的检索式文本
|
||||
if (group.originalQuery) {
|
||||
existingGroupsMap.set(group.originalQuery, group)
|
||||
}
|
||||
})
|
||||
|
||||
// 逐条检索并收集结果,不依赖后端保存的数据进行展示
|
||||
// 逐条检索并收集结果,保留历史结果
|
||||
for (let i = 0; i < queries.length; i++) {
|
||||
const q = queries[i]
|
||||
const searchTerm = sanitizeForClinicalTrials(q)
|
||||
if (!searchTerm) {
|
||||
// 若清洗后为空,则跳过该条但仍保留一个空分组用于可见性
|
||||
clinicalTrialsGroups.value.push({
|
||||
name: `CLINICAL_TRIALS_${i + 1}`,
|
||||
label: makeClinicalTabLabel(q, i),
|
||||
data: []
|
||||
})
|
||||
// 若清洗后为空,则跳过该条
|
||||
continue
|
||||
}
|
||||
// 调用现有接口(该接口会清空并保存到后端),但我们直接使用返回值渲染分组,避免相互覆盖的问题
|
||||
|
||||
// 检查是否已存在该检索式的分组
|
||||
let targetGroup = existingGroupsMap.get(q)
|
||||
if (!targetGroup) {
|
||||
// 创建新分组,使用时间戳确保唯一性
|
||||
const timestamp = Date.now()
|
||||
const groupName = `CLINICAL_TRIALS_${timestamp}_${i}`
|
||||
targetGroup = {
|
||||
name: groupName,
|
||||
label: makeClinicalTabLabel(q, clinicalTrialsGroups.value.length),
|
||||
originalQuery: q,
|
||||
data: []
|
||||
}
|
||||
clinicalTrialsGroups.value.push(targetGroup)
|
||||
existingGroupsMap.set(q, targetGroup)
|
||||
}
|
||||
|
||||
// 调用现有接口进行检索
|
||||
const result = await searchClinicalTrials(inquiry.value.id, searchTerm)
|
||||
clinicalTrialsGroups.value.push({
|
||||
name: `CLINICAL_TRIALS_${i + 1}`,
|
||||
label: makeClinicalTabLabel(q, i),
|
||||
data: result || []
|
||||
})
|
||||
|
||||
// 追加新结果到现有数据(避免重复,基于 NCT ID)
|
||||
if (result && result.length > 0) {
|
||||
const existingNctIds = new Set(targetGroup.data.map(item => item.nctId))
|
||||
result.forEach(item => {
|
||||
if (!existingNctIds.has(item.nctId)) {
|
||||
targetGroup.data.push(item)
|
||||
existingNctIds.add(item.nctId)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 默认激活第一个分组
|
||||
activeResultsTab.value = clinicalTrialsGroups.value[0]?.name || 'CLINICAL_TRIALS'
|
||||
// 如果执行了检索,默认激活第一个有结果的分组,或第一个分组
|
||||
if (clinicalTrialsGroups.value.length > 0) {
|
||||
const firstGroupWithData = clinicalTrialsGroups.value.find(g => g.data && g.data.length > 0)
|
||||
activeResultsTab.value = firstGroupWithData?.name || clinicalTrialsGroups.value[0].name
|
||||
}
|
||||
|
||||
ElMessage.success('临床试验搜索完成')
|
||||
} catch (error) {
|
||||
ElMessage.error('搜索失败: ' + (error.message || '未知错误'))
|
||||
|
|
@ -689,6 +872,82 @@ const handleExportClinicalTrials = () => {
|
|||
ElMessage.success('开始下载CSV文件')
|
||||
}
|
||||
|
||||
// 加载并分组知网结果(按 rpaTaskId 或检索式标签),保留历史结果
|
||||
const loadCnkiResults = async () => {
|
||||
try {
|
||||
const id = route.params.id
|
||||
const all = await getSearchResults(id)
|
||||
const list = (all || []).filter(it => (it.source || '').toUpperCase() === 'CNKI')
|
||||
|
||||
// 保留现有分组,使用 Map 来合并新数据
|
||||
const existingGroupsMap = new Map()
|
||||
cnkiGroups.value.forEach(group => {
|
||||
existingGroupsMap.set(group.name, group)
|
||||
})
|
||||
|
||||
const groupsMap = new Map(existingGroupsMap)
|
||||
for (const item of list) {
|
||||
let taskId = null
|
||||
let label = 'CNKI'
|
||||
let queryExpression = ''
|
||||
try {
|
||||
const meta = item.metadata ? JSON.parse(item.metadata) : null
|
||||
if (meta && meta.rpaTaskId) {
|
||||
taskId = meta.rpaTaskId
|
||||
queryExpression = meta.queryExpression || ''
|
||||
const snippet = queryExpression.slice(0, 16)
|
||||
label = `CNKI - ${snippet}${snippet.length < queryExpression.length ? '…' : ''}`
|
||||
}
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
const key = taskId ? `CNKI_${taskId}` : 'CNKI_DEFAULT'
|
||||
if (!groupsMap.has(key)) {
|
||||
groupsMap.set(key, { name: key, label, data: [], originalQuery: queryExpression })
|
||||
}
|
||||
const group = groupsMap.get(key)
|
||||
// 避免重复添加(基于 ID)
|
||||
const existingIds = new Set(group.data.map(d => d.id))
|
||||
if (!existingIds.has(item.id)) {
|
||||
group.data.push(item)
|
||||
}
|
||||
}
|
||||
cnkiGroups.value = Array.from(groupsMap.values())
|
||||
if (cnkiGroups.value.length > 0) {
|
||||
// 如果没有激活的tab或者当前tab不存在,切换到第一个
|
||||
if (!activeCnkiTab.value || !cnkiGroups.value.find(g => g.name === activeCnkiTab.value)) {
|
||||
activeCnkiTab.value = cnkiGroups.value[0].name
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
// 记录每个分组的选中项
|
||||
const onCnkiSelectionChange = (groupName, selection) => {
|
||||
cnkiSelections.value = {
|
||||
...(cnkiSelections.value || {}),
|
||||
[groupName]: selection
|
||||
}
|
||||
}
|
||||
|
||||
// 将当前Tab选中的知网条目加入待下载
|
||||
const handleAddCnkiToDownload = async () => {
|
||||
const selected = currentCnkiSelection.value || []
|
||||
if (!selected.length) {
|
||||
ElMessage.warning('请先选择需要加入下载的记录')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const ids = selected.map(item => item.id)
|
||||
await generateDownloadTasks(inquiry.value.id, ids)
|
||||
ElMessage.success(`已加入下载:${ids.length} 条`)
|
||||
} catch (error) {
|
||||
ElMessage.error('加入下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
const isStructuredKeywords = (keywords) => {
|
||||
try {
|
||||
const parsed = JSON.parse(keywords)
|
||||
|
|
@ -720,6 +979,26 @@ const getKeywordField = (keywords, field) => {
|
|||
const goToResponseView = () => {
|
||||
router.push(`/inquiry/${inquiry.value.id}/response-view`)
|
||||
}
|
||||
|
||||
// 从content字段中提取卷期信息
|
||||
const extractVolumeIssue = (content) => {
|
||||
if (!content) return '-'
|
||||
const match = content.match(/卷:(\d+),\s*期:(\d+)/)
|
||||
if (match) {
|
||||
return `${match[1]}(${match[2]})`
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
|
||||
// 从content字段中提取页码信息
|
||||
const extractPages = (content) => {
|
||||
if (!content) return '-'
|
||||
const match = content.match(/页码:([^,]+)/)
|
||||
if (match) {
|
||||
return match[1].trim()
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,368 @@
|
|||
<template>
|
||||
<div class="rpa-tasks">
|
||||
<el-card v-loading="loading">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>RPA 任务管理</span>
|
||||
<div>
|
||||
<el-switch
|
||||
v-model="autoRefresh"
|
||||
active-text="自动刷新"
|
||||
inactive-text="手动刷新"
|
||||
style="margin-right: 10px;"
|
||||
@change="handleAutoRefreshChange"
|
||||
/>
|
||||
<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="pendingCount" value-style="color: #E6A23C">
|
||||
<template #suffix>个</template>
|
||||
</el-statistic>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-statistic title="执行中" :value="runningCount" 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="关于 RPA 任务"
|
||||
type="info"
|
||||
:closable="false"
|
||||
style="margin-bottom: 20px;"
|
||||
>
|
||||
<div>
|
||||
RPA 机器人会定期(约每 20 分钟)访问此页面,认领待处理任务。
|
||||
<br />
|
||||
任务完成后,机器人会将结果回传到系统,页面会自动更新任务状态。
|
||||
<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="RUNNING">执行中</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="filterSource"
|
||||
placeholder="筛选数据源"
|
||||
clearable
|
||||
style="width: 200px; margin-left: 15px;"
|
||||
@change="loadTasks"
|
||||
>
|
||||
<el-option label="知网 (CNKI)" value="CNKI" />
|
||||
<el-option label="万方 (WANFANG)" value="WANFANG" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="selectedInquiryId"
|
||||
placeholder="筛选查询请求"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 300px; margin-left: 15px;"
|
||||
@change="loadTasks"
|
||||
>
|
||||
<el-option
|
||||
v-for="inquiry in uniqueInquiries"
|
||||
:key="inquiry.id"
|
||||
:label="`REQ-${inquiry.id} - ${inquiry.queryExpression || '未命名'}`"
|
||||
:value="inquiry.id"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<el-table :data="displayTasks" border stripe style="margin-top: 20px;" v-loading="loading">
|
||||
<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.inquiryId)"
|
||||
>
|
||||
#{{ row.inquiryId }}
|
||||
</el-link>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="数据源">
|
||||
<el-tag :type="row.source === 'CNKI' ? 'success' : 'primary'">
|
||||
{{ row.source === 'CNKI' ? '知网' : '万方' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="getStatusTagType(row.status)">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="检索式" :span="2">
|
||||
{{ row.queryExpression }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ formatDateTime(row.createdAt) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="开始时间">
|
||||
{{ row.startedAt ? formatDateTime(row.startedAt) : '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="完成时间">
|
||||
{{ row.finishedAt ? formatDateTime(row.finishedAt) : '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">
|
||||
{{ row.remark || '-' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="id" label="任务ID" width="100" />
|
||||
<el-table-column prop="inquiryId" label="查询ID" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" @click="goToInquiry(row.inquiryId)">
|
||||
#{{ row.inquiryId }}
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source" label="数据源" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.source === 'CNKI' ? 'success' : 'primary'" size="small">
|
||||
{{ row.source === 'CNKI' ? '知网' : '万方' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusTagType(row.status)" size="small">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="queryExpression" label="检索式" min-width="300" show-overflow-tooltip />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="startedAt" label="开始时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.startedAt ? formatDateTime(row.startedAt) : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="finishedAt" label="完成时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.finishedAt ? formatDateTime(row.finishedAt) : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-if="!loading && displayTasks.length === 0" description="暂无任务数据" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getAllRpaTasks } from '@/api/inquiry'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const tasks = ref([])
|
||||
const filterStatus = ref('all')
|
||||
const filterSource = ref('')
|
||||
const selectedInquiryId = ref(null)
|
||||
const autoRefresh = ref(true)
|
||||
let refreshTimer = null
|
||||
|
||||
const pendingCount = computed(() => {
|
||||
return tasks.value.filter(t => t.status === 'PENDING').length
|
||||
})
|
||||
|
||||
const runningCount = computed(() => {
|
||||
return tasks.value.filter(t => t.status === 'RUNNING').length
|
||||
})
|
||||
|
||||
const completedCount = computed(() => {
|
||||
return tasks.value.filter(t => t.status === 'COMPLETED').length
|
||||
})
|
||||
|
||||
const failedCount = computed(() => {
|
||||
return tasks.value.filter(t => t.status === 'FAILED').length
|
||||
})
|
||||
|
||||
const displayTasks = computed(() => {
|
||||
let filtered = tasks.value
|
||||
|
||||
// 按状态筛选
|
||||
if (filterStatus.value !== 'all') {
|
||||
filtered = filtered.filter(t => t.status === filterStatus.value)
|
||||
}
|
||||
|
||||
// 按数据源筛选
|
||||
if (filterSource.value) {
|
||||
filtered = filtered.filter(t => t.source === filterSource.value)
|
||||
}
|
||||
|
||||
// 按查询请求筛选
|
||||
if (selectedInquiryId.value) {
|
||||
filtered = filtered.filter(t => t.inquiryId === selectedInquiryId.value)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const uniqueInquiries = computed(() => {
|
||||
const inquiryMap = new Map()
|
||||
tasks.value.forEach(task => {
|
||||
if (!inquiryMap.has(task.inquiryId)) {
|
||||
inquiryMap.set(task.inquiryId, {
|
||||
id: task.inquiryId,
|
||||
queryExpression: task.queryExpression
|
||||
})
|
||||
}
|
||||
})
|
||||
return Array.from(inquiryMap.values())
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadTasks()
|
||||
if (autoRefresh.value) {
|
||||
startAutoRefresh()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
|
||||
const loadTasks = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getAllRpaTasks()
|
||||
tasks.value = data || []
|
||||
} catch (error) {
|
||||
ElMessage.error('加载任务失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadTasks()
|
||||
}
|
||||
|
||||
const handleAutoRefreshChange = (val) => {
|
||||
if (val) {
|
||||
startAutoRefresh()
|
||||
} else {
|
||||
stopAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
const startAutoRefresh = () => {
|
||||
// 每 30 秒自动刷新一次
|
||||
refreshTimer = setInterval(() => {
|
||||
loadTasks()
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const goToInquiry = (inquiryId) => {
|
||||
router.push(`/inquiry/detail/${inquiryId}`)
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const getStatusTagType = (status) => {
|
||||
const typeMap = {
|
||||
'PENDING': 'warning',
|
||||
'RUNNING': 'primary',
|
||||
'COMPLETED': 'success',
|
||||
'FAILED': 'danger'
|
||||
}
|
||||
return typeMap[status] || 'info'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const textMap = {
|
||||
'PENDING': '待认领',
|
||||
'RUNNING': '执行中',
|
||||
'COMPLETED': '已完成',
|
||||
'FAILED': '失败'
|
||||
}
|
||||
return textMap[status] || status
|
||||
}
|
||||
|
||||
const formatDateTime = (dateTime) => {
|
||||
if (!dateTime) return '-'
|
||||
const date = new Date(dateTime)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rpa-tasks {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.expand-content {
|
||||
padding: 20px;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue