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:
williamWan 2025-10-31 18:22:41 +08:00
parent 29d0950c6f
commit 769a7518da
21 changed files with 1902 additions and 78 deletions

432
Readme-RPA.md Normal file
View File

@ -0,0 +1,432 @@
# RPA 对接约定CNKI/万方检索)
本文档用于终端 RPA影刀配置参考说明任务拉取、状态回传与结果上报协议。当前后端已提供完整 REST API可直接通过 HTTP 调用。
## 1. 基本信息
- 基础地址Base URL根据部署而定例如 `http://localhost:8080/api`
- 注意:后端配置了 context-path `/api`,所有接口路径需要加上此前缀
- 鉴权:当前环境默认放开 CORS无鉴权头生产建议加网关或 Token 认证
- 编码UTF-8JSON 请求与响应
## 2. 名词与对象
- Inquiry查询请求一次客户问题受理流程的载体
- RpaTaskRPA 任务面向某一数据源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"
]
}
```
Response200
```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任务按创建时间倒序排列。
Response200
```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升序排列。
Response200
```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`(不区分大小写,自动转换)
Response200
```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`
Response200
```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` 已存在,会保留原有内容
Response200
```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"
Response200
```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`(可选):失败原因说明
Response200
```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`任务IDLong
- `inquiryId`关联的查询请求IDLong
- `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 IDString可选
- `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`:执行失败,可重新拉取并重试

View File

@ -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));
}
}

View File

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

View File

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

View File

@ -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;
}

View File

@ -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;
}

View File

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

View File

@ -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);
}

View File

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

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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("未选中任何需要下载的条目");

View File

@ -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;
}
}

View File

@ -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自动化检索任务表';

View File

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

View File

@ -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',

View File

@ -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'
})
}

View File

@ -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' }
}
]
},

View File

@ -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}`)

View File

@ -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) {
// tabtab
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>

View File

@ -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>