You've already forked DataMate
feat(kg): 实现 Phase 3.2 Human-in-the-loop 编辑
核心功能: - 实体/关系编辑表单(创建/更新/删除) - 批量操作(批量删除节点/边) - 审核流程(提交审核 → 待审核列表 → 通过/拒绝) - 编辑模式切换(查看/编辑模式) - 权限控制(knowledgeGraphWrite 权限) 新增文件(后端,9 个): - EditReview.java - 审核记录领域模型(Neo4j 节点) - EditReviewRepository.java - 审核记录仓储(CRUD + 分页查询) - EditReviewService.java - 审核业务服务(提交/通过/拒绝,通过时自动执行变更) - EditReviewController.java - REST API(POST submit, POST approve/reject, GET pending) - DTOs: SubmitReviewRequest, EditReviewVO, ReviewActionRequest, BatchDeleteRequest - EditReviewServiceTest.java - 单元测试(21 tests) - EditReviewControllerTest.java - 集成测试(10 tests) 新增文件(前端,3 个): - EntityEditForm.tsx - 实体创建/编辑表单(Modal,支持名称/类型/描述/别名/置信度) - RelationEditForm.tsx - 关系创建/编辑表单(Modal,支持源/目标实体搜索、关系类型、权重/置信度) - ReviewPanel.tsx - 审核面板(待审核列表,通过/拒绝操作,拒绝带备注) 修改文件(后端,7 个): - GraphEntityService.java - 新增 batchDeleteEntities(),updateEntity 支持 confidence - GraphRelationService.java - 新增 batchDeleteRelations() - GraphEntityController.java - 删除批量删除端点(改为审核流程) - GraphRelationController.java - 删除批量删除端点(改为审核流程) - UpdateEntityRequest.java - 添加 confidence 字段 - KnowledgeGraphErrorCode.java - 新增 REVIEW_NOT_FOUND、REVIEW_ALREADY_PROCESSED - PermissionRuleMatcher.java - 添加 /api/knowledge-graph/** 写操作权限规则 修改文件(前端,8 个): - knowledge-graph.model.ts - 新增 EditReviewVO、ReviewOperationType、ReviewStatus 类型 - knowledge-graph.api.ts - BASE 改为 /api/knowledge-graph(走网关权限链),新增审核相关 API,删除批量删除直删方法 - vite.config.ts - 更新 dev proxy 路径 - NodeDetail.tsx - 新增 editMode 属性,编辑模式下显示编辑/删除按钮 - RelationDetail.tsx - 新增 editMode 属性,编辑模式下显示编辑/删除按钮 - KnowledgeGraphPage.tsx - 新增编辑模式开关(需要 knowledgeGraphWrite 权限)、创建实体/关系工具栏按钮、审核 Tab、批量操作 - GraphCanvas.tsx - 支持多选(editMode 时)、onSelectionChange 回调 - graphConfig.ts - 支持 multiSelect 参数 审核流程: - 所有编辑操作(创建/更新/删除/批量删除)都通过 submitReview 提交审核 - 审核通过后,EditReviewService.applyChange() 自动执行变更 - 批量删除端点已删除,只能通过审核流程 权限控制: - API 路径从 /knowledge-graph 改为 /api/knowledge-graph,走网关权限链 - 编辑模式开关需要 knowledgeGraphWrite 权限 - PermissionRuleMatcher 添加 /api/knowledge-graph/** 写操作规则 Bug 修复(Codex 审查后修复): - P0: 权限绕过(API 路径改为 /api/knowledge-graph) - P1: 审核流程未接入(所有编辑操作改为 submitReview) - P1: 批量删除绕过审核(删除直删端点,改为审核流程) - P1: confidence 字段丢失(UpdateEntityRequest 添加 confidence) - P2: 审核提交校验不足(添加跨字段校验器) - P2: 批量删除安全(添加 @Size(max=100) 限制,收集失败 ID) - P2: 前端错误处理(分开处理表单校验和 API 失败) 测试结果: - 后端: 311 tests pass ✅ (280 → 311, +31 new) - 前端: eslint clean ✅, tsc clean ✅, vite build success ✅
This commit is contained in:
@@ -49,6 +49,7 @@ public class PermissionRuleMatcher {
|
|||||||
addModuleRules(permissionRules, "/api/orchestration/**", "module:orchestration:read", "module:orchestration:write");
|
addModuleRules(permissionRules, "/api/orchestration/**", "module:orchestration:read", "module:orchestration:write");
|
||||||
addModuleRules(permissionRules, "/api/content-generation/**", "module:content-generation:use", "module:content-generation:use");
|
addModuleRules(permissionRules, "/api/content-generation/**", "module:content-generation:use", "module:content-generation:use");
|
||||||
addModuleRules(permissionRules, "/api/task-meta/**", "module:task-coordination:read", "module:task-coordination:write");
|
addModuleRules(permissionRules, "/api/task-meta/**", "module:task-coordination:read", "module:task-coordination:write");
|
||||||
|
addModuleRules(permissionRules, "/api/knowledge-graph/**", "module:knowledge-graph:read", "module:knowledge-graph:write");
|
||||||
|
|
||||||
permissionRules.add(new PermissionRule(READ_METHODS, "/api/auth/users/**", "system:user:manage"));
|
permissionRules.add(new PermissionRule(READ_METHODS, "/api/auth/users/**", "system:user:manage"));
|
||||||
permissionRules.add(new PermissionRule(WRITE_METHODS, "/api/auth/users/**", "system:user:manage"));
|
permissionRules.add(new PermissionRule(WRITE_METHODS, "/api/auth/users/**", "system:user:manage"));
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package com.datamate.knowledgegraph.application;
|
||||||
|
|
||||||
|
import com.datamate.common.infrastructure.exception.BusinessException;
|
||||||
|
import com.datamate.common.infrastructure.exception.SystemErrorCode;
|
||||||
|
import com.datamate.common.interfaces.PagedResponse;
|
||||||
|
import com.datamate.knowledgegraph.domain.model.EditReview;
|
||||||
|
import com.datamate.knowledgegraph.domain.repository.EditReviewRepository;
|
||||||
|
import com.datamate.knowledgegraph.infrastructure.exception.KnowledgeGraphErrorCode;
|
||||||
|
import com.datamate.knowledgegraph.interfaces.dto.*;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
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.List;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑审核业务服务。
|
||||||
|
* <p>
|
||||||
|
* 提供编辑审核的提交、审批、拒绝和查询功能。
|
||||||
|
* 审批通过后自动调用对应的实体/关系 CRUD 服务执行变更。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class EditReviewService {
|
||||||
|
|
||||||
|
private static final long MAX_SKIP = 100_000L;
|
||||||
|
private static final Pattern UUID_PATTERN = Pattern.compile(
|
||||||
|
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
||||||
|
);
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private final EditReviewRepository reviewRepository;
|
||||||
|
private final GraphEntityService entityService;
|
||||||
|
private final GraphRelationService relationService;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public EditReviewVO submitReview(String graphId, SubmitReviewRequest request, String submittedBy) {
|
||||||
|
validateGraphId(graphId);
|
||||||
|
|
||||||
|
EditReview review = EditReview.builder()
|
||||||
|
.graphId(graphId)
|
||||||
|
.operationType(request.getOperationType())
|
||||||
|
.entityId(request.getEntityId())
|
||||||
|
.relationId(request.getRelationId())
|
||||||
|
.payload(request.getPayload())
|
||||||
|
.status("PENDING")
|
||||||
|
.submittedBy(submittedBy)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
EditReview saved = reviewRepository.save(review);
|
||||||
|
log.info("Review submitted: id={}, graphId={}, type={}, by={}",
|
||||||
|
saved.getId(), graphId, request.getOperationType(), submittedBy);
|
||||||
|
return toVO(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public EditReviewVO approveReview(String graphId, String reviewId, String reviewedBy, String comment) {
|
||||||
|
validateGraphId(graphId);
|
||||||
|
|
||||||
|
EditReview review = reviewRepository.findById(reviewId, graphId)
|
||||||
|
.orElseThrow(() -> BusinessException.of(KnowledgeGraphErrorCode.REVIEW_NOT_FOUND));
|
||||||
|
|
||||||
|
if (!"PENDING".equals(review.getStatus())) {
|
||||||
|
throw BusinessException.of(KnowledgeGraphErrorCode.REVIEW_ALREADY_PROCESSED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the change
|
||||||
|
applyChange(review);
|
||||||
|
|
||||||
|
// Update review status
|
||||||
|
review.setStatus("APPROVED");
|
||||||
|
review.setReviewedBy(reviewedBy);
|
||||||
|
review.setReviewComment(comment);
|
||||||
|
review.setReviewedAt(LocalDateTime.now());
|
||||||
|
reviewRepository.save(review);
|
||||||
|
|
||||||
|
log.info("Review approved: id={}, graphId={}, type={}, by={}",
|
||||||
|
reviewId, graphId, review.getOperationType(), reviewedBy);
|
||||||
|
return toVO(review);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public EditReviewVO rejectReview(String graphId, String reviewId, String reviewedBy, String comment) {
|
||||||
|
validateGraphId(graphId);
|
||||||
|
|
||||||
|
EditReview review = reviewRepository.findById(reviewId, graphId)
|
||||||
|
.orElseThrow(() -> BusinessException.of(KnowledgeGraphErrorCode.REVIEW_NOT_FOUND));
|
||||||
|
|
||||||
|
if (!"PENDING".equals(review.getStatus())) {
|
||||||
|
throw BusinessException.of(KnowledgeGraphErrorCode.REVIEW_ALREADY_PROCESSED);
|
||||||
|
}
|
||||||
|
|
||||||
|
review.setStatus("REJECTED");
|
||||||
|
review.setReviewedBy(reviewedBy);
|
||||||
|
review.setReviewComment(comment);
|
||||||
|
review.setReviewedAt(LocalDateTime.now());
|
||||||
|
reviewRepository.save(review);
|
||||||
|
|
||||||
|
log.info("Review rejected: id={}, graphId={}, type={}, by={}",
|
||||||
|
reviewId, graphId, review.getOperationType(), reviewedBy);
|
||||||
|
return toVO(review);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PagedResponse<EditReviewVO> listPendingReviews(String graphId, int page, int size) {
|
||||||
|
validateGraphId(graphId);
|
||||||
|
|
||||||
|
int safePage = Math.max(0, page);
|
||||||
|
int safeSize = Math.max(1, Math.min(size, 200));
|
||||||
|
long skip = (long) safePage * safeSize;
|
||||||
|
if (skip > MAX_SKIP) {
|
||||||
|
throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER, "分页偏移量过大");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<EditReview> reviews = reviewRepository.findPendingByGraphId(graphId, skip, safeSize);
|
||||||
|
long total = reviewRepository.countPendingByGraphId(graphId);
|
||||||
|
long totalPages = safeSize > 0 ? (total + safeSize - 1) / safeSize : 0;
|
||||||
|
|
||||||
|
List<EditReviewVO> content = reviews.stream().map(EditReviewService::toVO).toList();
|
||||||
|
return PagedResponse.of(content, safePage, total, totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PagedResponse<EditReviewVO> listReviews(String graphId, String status, int page, int size) {
|
||||||
|
validateGraphId(graphId);
|
||||||
|
|
||||||
|
int safePage = Math.max(0, page);
|
||||||
|
int safeSize = Math.max(1, Math.min(size, 200));
|
||||||
|
long skip = (long) safePage * safeSize;
|
||||||
|
if (skip > MAX_SKIP) {
|
||||||
|
throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER, "分页偏移量过大");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<EditReview> reviews = reviewRepository.findByGraphId(graphId, status, skip, safeSize);
|
||||||
|
long total = reviewRepository.countByGraphId(graphId, status);
|
||||||
|
long totalPages = safeSize > 0 ? (total + safeSize - 1) / safeSize : 0;
|
||||||
|
|
||||||
|
List<EditReviewVO> content = reviews.stream().map(EditReviewService::toVO).toList();
|
||||||
|
return PagedResponse.of(content, safePage, total, totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 执行变更
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private void applyChange(EditReview review) {
|
||||||
|
String graphId = review.getGraphId();
|
||||||
|
String type = review.getOperationType();
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (type) {
|
||||||
|
case "CREATE_ENTITY" -> {
|
||||||
|
CreateEntityRequest req = MAPPER.readValue(review.getPayload(), CreateEntityRequest.class);
|
||||||
|
entityService.createEntity(graphId, req);
|
||||||
|
}
|
||||||
|
case "UPDATE_ENTITY" -> {
|
||||||
|
UpdateEntityRequest req = MAPPER.readValue(review.getPayload(), UpdateEntityRequest.class);
|
||||||
|
entityService.updateEntity(graphId, review.getEntityId(), req);
|
||||||
|
}
|
||||||
|
case "DELETE_ENTITY" -> {
|
||||||
|
entityService.deleteEntity(graphId, review.getEntityId());
|
||||||
|
}
|
||||||
|
case "BATCH_DELETE_ENTITY" -> {
|
||||||
|
BatchDeleteRequest req = MAPPER.readValue(review.getPayload(), BatchDeleteRequest.class);
|
||||||
|
entityService.batchDeleteEntities(graphId, req.getIds());
|
||||||
|
}
|
||||||
|
case "CREATE_RELATION" -> {
|
||||||
|
CreateRelationRequest req = MAPPER.readValue(review.getPayload(), CreateRelationRequest.class);
|
||||||
|
relationService.createRelation(graphId, req);
|
||||||
|
}
|
||||||
|
case "UPDATE_RELATION" -> {
|
||||||
|
UpdateRelationRequest req = MAPPER.readValue(review.getPayload(), UpdateRelationRequest.class);
|
||||||
|
relationService.updateRelation(graphId, review.getRelationId(), req);
|
||||||
|
}
|
||||||
|
case "DELETE_RELATION" -> {
|
||||||
|
relationService.deleteRelation(graphId, review.getRelationId());
|
||||||
|
}
|
||||||
|
case "BATCH_DELETE_RELATION" -> {
|
||||||
|
BatchDeleteRequest req = MAPPER.readValue(review.getPayload(), BatchDeleteRequest.class);
|
||||||
|
relationService.batchDeleteRelations(graphId, req.getIds());
|
||||||
|
}
|
||||||
|
default -> throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER, "未知操作类型: " + type);
|
||||||
|
}
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER, "变更载荷解析失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 转换
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static EditReviewVO toVO(EditReview review) {
|
||||||
|
return EditReviewVO.builder()
|
||||||
|
.id(review.getId())
|
||||||
|
.graphId(review.getGraphId())
|
||||||
|
.operationType(review.getOperationType())
|
||||||
|
.entityId(review.getEntityId())
|
||||||
|
.relationId(review.getRelationId())
|
||||||
|
.payload(review.getPayload())
|
||||||
|
.status(review.getStatus())
|
||||||
|
.submittedBy(review.getSubmittedBy())
|
||||||
|
.reviewedBy(review.getReviewedBy())
|
||||||
|
.reviewComment(review.getReviewComment())
|
||||||
|
.createdAt(review.getCreatedAt())
|
||||||
|
.reviewedAt(review.getReviewedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateGraphId(String graphId) {
|
||||||
|
if (graphId == null || !UUID_PATTERN.matcher(graphId).matches()) {
|
||||||
|
throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER, "graphId 格式无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,9 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -147,6 +149,9 @@ public class GraphEntityService {
|
|||||||
if (request.getProperties() != null) {
|
if (request.getProperties() != null) {
|
||||||
entity.setProperties(request.getProperties());
|
entity.setProperties(request.getProperties());
|
||||||
}
|
}
|
||||||
|
if (request.getConfidence() != null) {
|
||||||
|
entity.setConfidence(request.getConfidence());
|
||||||
|
}
|
||||||
entity.setUpdatedAt(LocalDateTime.now());
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
GraphEntity saved = entityRepository.save(entity);
|
GraphEntity saved = entityRepository.save(entity);
|
||||||
cacheService.evictEntityCaches(graphId, entityId);
|
cacheService.evictEntityCaches(graphId, entityId);
|
||||||
@@ -170,6 +175,28 @@ public class GraphEntityService {
|
|||||||
return entityRepository.findNeighbors(graphId, entityId, clampedDepth, clampedLimit);
|
return entityRepository.findNeighbors(graphId, entityId, clampedDepth, clampedLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> batchDeleteEntities(String graphId, List<String> entityIds) {
|
||||||
|
validateGraphId(graphId);
|
||||||
|
int deleted = 0;
|
||||||
|
List<String> failedIds = new ArrayList<>();
|
||||||
|
for (String entityId : entityIds) {
|
||||||
|
try {
|
||||||
|
deleteEntity(graphId, entityId);
|
||||||
|
deleted++;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Batch delete: failed to delete entity {}: {}", entityId, e.getMessage());
|
||||||
|
failedIds.add(entityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<String, Object> result = Map.of(
|
||||||
|
"deleted", deleted,
|
||||||
|
"total", entityIds.size(),
|
||||||
|
"failedIds", failedIds
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public long countEntities(String graphId) {
|
public long countEntities(String graphId) {
|
||||||
validateGraphId(graphId);
|
validateGraphId(graphId);
|
||||||
return entityRepository.countByGraphId(graphId);
|
return entityRepository.countByGraphId(graphId);
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
@@ -188,6 +190,28 @@ public class GraphRelationService {
|
|||||||
cacheService.evictEntityCaches(graphId, relationId);
|
cacheService.evictEntityCaches(graphId, relationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> batchDeleteRelations(String graphId, List<String> relationIds) {
|
||||||
|
validateGraphId(graphId);
|
||||||
|
int deleted = 0;
|
||||||
|
List<String> failedIds = new ArrayList<>();
|
||||||
|
for (String relationId : relationIds) {
|
||||||
|
try {
|
||||||
|
deleteRelation(graphId, relationId);
|
||||||
|
deleted++;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Batch delete: failed to delete relation {}: {}", relationId, e.getMessage());
|
||||||
|
failedIds.add(relationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<String, Object> result = Map.of(
|
||||||
|
"deleted", deleted,
|
||||||
|
"total", relationIds.size(),
|
||||||
|
"failedIds", failedIds
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// 领域对象 → 视图对象 转换
|
// 领域对象 → 视图对象 转换
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.datamate.knowledgegraph.domain.model;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识图谱编辑审核记录。
|
||||||
|
* <p>
|
||||||
|
* 在 Neo4j 中作为 {@code EditReview} 节点存储,
|
||||||
|
* 记录实体/关系的增删改请求及审核状态。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class EditReview {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
/** 所属图谱 ID */
|
||||||
|
private String graphId;
|
||||||
|
|
||||||
|
/** 操作类型:CREATE_ENTITY, UPDATE_ENTITY, DELETE_ENTITY, BATCH_DELETE_ENTITY, CREATE_RELATION, UPDATE_RELATION, DELETE_RELATION, BATCH_DELETE_RELATION */
|
||||||
|
private String operationType;
|
||||||
|
|
||||||
|
/** 目标实体 ID(实体操作时非空) */
|
||||||
|
private String entityId;
|
||||||
|
|
||||||
|
/** 目标关系 ID(关系操作时非空) */
|
||||||
|
private String relationId;
|
||||||
|
|
||||||
|
/** 变更载荷(JSON 序列化的请求体) */
|
||||||
|
private String payload;
|
||||||
|
|
||||||
|
/** 审核状态:PENDING, APPROVED, REJECTED */
|
||||||
|
@Builder.Default
|
||||||
|
private String status = "PENDING";
|
||||||
|
|
||||||
|
/** 提交人 ID */
|
||||||
|
private String submittedBy;
|
||||||
|
|
||||||
|
/** 审核人 ID */
|
||||||
|
private String reviewedBy;
|
||||||
|
|
||||||
|
/** 审核意见 */
|
||||||
|
private String reviewComment;
|
||||||
|
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
private LocalDateTime reviewedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package com.datamate.knowledgegraph.domain.repository;
|
||||||
|
|
||||||
|
import com.datamate.knowledgegraph.domain.model.EditReview;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.neo4j.driver.Value;
|
||||||
|
import org.neo4j.driver.types.MapAccessor;
|
||||||
|
import org.springframework.data.neo4j.core.Neo4jClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑审核仓储。
|
||||||
|
* <p>
|
||||||
|
* 使用 {@code Neo4jClient} 管理 {@code EditReview} 节点。
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class EditReviewRepository {
|
||||||
|
|
||||||
|
private final Neo4jClient neo4jClient;
|
||||||
|
|
||||||
|
public EditReview save(EditReview review) {
|
||||||
|
if (review.getId() == null) {
|
||||||
|
review.setId(UUID.randomUUID().toString());
|
||||||
|
}
|
||||||
|
if (review.getCreatedAt() == null) {
|
||||||
|
review.setCreatedAt(LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
params.put("id", review.getId());
|
||||||
|
params.put("graphId", review.getGraphId());
|
||||||
|
params.put("operationType", review.getOperationType());
|
||||||
|
params.put("entityId", review.getEntityId() != null ? review.getEntityId() : "");
|
||||||
|
params.put("relationId", review.getRelationId() != null ? review.getRelationId() : "");
|
||||||
|
params.put("payload", review.getPayload() != null ? review.getPayload() : "");
|
||||||
|
params.put("status", review.getStatus());
|
||||||
|
params.put("submittedBy", review.getSubmittedBy() != null ? review.getSubmittedBy() : "");
|
||||||
|
params.put("reviewedBy", review.getReviewedBy() != null ? review.getReviewedBy() : "");
|
||||||
|
params.put("reviewComment", review.getReviewComment() != null ? review.getReviewComment() : "");
|
||||||
|
params.put("createdAt", review.getCreatedAt());
|
||||||
|
params.put("reviewedAt", review.getReviewedAt());
|
||||||
|
|
||||||
|
neo4jClient
|
||||||
|
.query(
|
||||||
|
"MERGE (r:EditReview {id: $id}) " +
|
||||||
|
"SET r.graph_id = $graphId, " +
|
||||||
|
" r.operation_type = $operationType, " +
|
||||||
|
" r.entity_id = $entityId, " +
|
||||||
|
" r.relation_id = $relationId, " +
|
||||||
|
" r.payload = $payload, " +
|
||||||
|
" r.status = $status, " +
|
||||||
|
" r.submitted_by = $submittedBy, " +
|
||||||
|
" r.reviewed_by = $reviewedBy, " +
|
||||||
|
" r.review_comment = $reviewComment, " +
|
||||||
|
" r.created_at = $createdAt, " +
|
||||||
|
" r.reviewed_at = $reviewedAt " +
|
||||||
|
"RETURN r"
|
||||||
|
)
|
||||||
|
.bindAll(params)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return review;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<EditReview> findById(String reviewId, String graphId) {
|
||||||
|
return neo4jClient
|
||||||
|
.query("MATCH (r:EditReview {id: $id, graph_id: $graphId}) RETURN r")
|
||||||
|
.bindAll(Map.of("id", reviewId, "graphId", graphId))
|
||||||
|
.fetchAs(EditReview.class)
|
||||||
|
.mappedBy((typeSystem, record) -> mapRecord(record))
|
||||||
|
.one();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<EditReview> findPendingByGraphId(String graphId, long skip, int size) {
|
||||||
|
return neo4jClient
|
||||||
|
.query(
|
||||||
|
"MATCH (r:EditReview {graph_id: $graphId, status: 'PENDING'}) " +
|
||||||
|
"RETURN r ORDER BY r.created_at DESC SKIP $skip LIMIT $size"
|
||||||
|
)
|
||||||
|
.bindAll(Map.of("graphId", graphId, "skip", skip, "size", size))
|
||||||
|
.fetchAs(EditReview.class)
|
||||||
|
.mappedBy((typeSystem, record) -> mapRecord(record))
|
||||||
|
.all()
|
||||||
|
.stream().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countPendingByGraphId(String graphId) {
|
||||||
|
return neo4jClient
|
||||||
|
.query("MATCH (r:EditReview {graph_id: $graphId, status: 'PENDING'}) RETURN count(r) AS cnt")
|
||||||
|
.bindAll(Map.of("graphId", graphId))
|
||||||
|
.fetchAs(Long.class)
|
||||||
|
.mappedBy((typeSystem, record) -> record.get("cnt").asLong())
|
||||||
|
.one()
|
||||||
|
.orElse(0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<EditReview> findByGraphId(String graphId, String status, long skip, int size) {
|
||||||
|
String statusFilter = (status != null && !status.isBlank())
|
||||||
|
? "AND r.status = $status "
|
||||||
|
: "";
|
||||||
|
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
params.put("graphId", graphId);
|
||||||
|
params.put("status", status != null ? status : "");
|
||||||
|
params.put("skip", skip);
|
||||||
|
params.put("size", size);
|
||||||
|
|
||||||
|
return neo4jClient
|
||||||
|
.query(
|
||||||
|
"MATCH (r:EditReview {graph_id: $graphId}) " +
|
||||||
|
"WHERE true " + statusFilter +
|
||||||
|
"RETURN r ORDER BY r.created_at DESC SKIP $skip LIMIT $size"
|
||||||
|
)
|
||||||
|
.bindAll(params)
|
||||||
|
.fetchAs(EditReview.class)
|
||||||
|
.mappedBy((typeSystem, record) -> mapRecord(record))
|
||||||
|
.all()
|
||||||
|
.stream().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countByGraphId(String graphId, String status) {
|
||||||
|
String statusFilter = (status != null && !status.isBlank())
|
||||||
|
? "AND r.status = $status "
|
||||||
|
: "";
|
||||||
|
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
params.put("graphId", graphId);
|
||||||
|
params.put("status", status != null ? status : "");
|
||||||
|
|
||||||
|
return neo4jClient
|
||||||
|
.query(
|
||||||
|
"MATCH (r:EditReview {graph_id: $graphId}) " +
|
||||||
|
"WHERE true " + statusFilter +
|
||||||
|
"RETURN count(r) AS cnt"
|
||||||
|
)
|
||||||
|
.bindAll(params)
|
||||||
|
.fetchAs(Long.class)
|
||||||
|
.mappedBy((typeSystem, record) -> record.get("cnt").asLong())
|
||||||
|
.one()
|
||||||
|
.orElse(0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 内部映射
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private EditReview mapRecord(MapAccessor record) {
|
||||||
|
Value r = record.get("r");
|
||||||
|
|
||||||
|
return EditReview.builder()
|
||||||
|
.id(getStringOrNull(r, "id"))
|
||||||
|
.graphId(getStringOrNull(r, "graph_id"))
|
||||||
|
.operationType(getStringOrNull(r, "operation_type"))
|
||||||
|
.entityId(getStringOrEmpty(r, "entity_id"))
|
||||||
|
.relationId(getStringOrEmpty(r, "relation_id"))
|
||||||
|
.payload(getStringOrNull(r, "payload"))
|
||||||
|
.status(getStringOrNull(r, "status"))
|
||||||
|
.submittedBy(getStringOrEmpty(r, "submitted_by"))
|
||||||
|
.reviewedBy(getStringOrEmpty(r, "reviewed_by"))
|
||||||
|
.reviewComment(getStringOrEmpty(r, "review_comment"))
|
||||||
|
.createdAt(getLocalDateTimeOrNull(r, "created_at"))
|
||||||
|
.reviewedAt(getLocalDateTimeOrNull(r, "reviewed_at"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getStringOrNull(Value value, String key) {
|
||||||
|
Value v = value.get(key);
|
||||||
|
return (v == null || v.isNull()) ? null : v.asString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getStringOrEmpty(Value value, String key) {
|
||||||
|
Value v = value.get(key);
|
||||||
|
if (v == null || v.isNull()) return null;
|
||||||
|
String s = v.asString();
|
||||||
|
return s.isEmpty() ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDateTime getLocalDateTimeOrNull(Value value, String key) {
|
||||||
|
Value v = value.get(key);
|
||||||
|
return (v == null || v.isNull()) ? null : v.asLocalDateTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,9 @@ public enum KnowledgeGraphErrorCode implements ErrorCode {
|
|||||||
QUERY_TIMEOUT("knowledge_graph.0014", "图查询超时,请缩小搜索范围或减少深度"),
|
QUERY_TIMEOUT("knowledge_graph.0014", "图查询超时,请缩小搜索范围或减少深度"),
|
||||||
SCHEMA_MIGRATION_FAILED("knowledge_graph.0015", "Schema 迁移执行失败"),
|
SCHEMA_MIGRATION_FAILED("knowledge_graph.0015", "Schema 迁移执行失败"),
|
||||||
SCHEMA_CHECKSUM_MISMATCH("knowledge_graph.0016", "Schema 迁移 checksum 不匹配:已应用的迁移被修改"),
|
SCHEMA_CHECKSUM_MISMATCH("knowledge_graph.0016", "Schema 迁移 checksum 不匹配:已应用的迁移被修改"),
|
||||||
SCHEMA_MIGRATION_LOCKED("knowledge_graph.0017", "Schema 迁移锁被占用,其他实例正在执行迁移");
|
SCHEMA_MIGRATION_LOCKED("knowledge_graph.0017", "Schema 迁移锁被占用,其他实例正在执行迁移"),
|
||||||
|
REVIEW_NOT_FOUND("knowledge_graph.0018", "审核记录不存在"),
|
||||||
|
REVIEW_ALREADY_PROCESSED("knowledge_graph.0019", "审核记录已处理");
|
||||||
|
|
||||||
private final String code;
|
private final String code;
|
||||||
private final String message;
|
private final String message;
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.datamate.knowledgegraph.interfaces.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除请求。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class BatchDeleteRequest {
|
||||||
|
|
||||||
|
@NotEmpty(message = "ID 列表不能为空")
|
||||||
|
@Size(max = 100, message = "单次批量删除最多 100 条")
|
||||||
|
private List<String> ids;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.datamate.knowledgegraph.interfaces.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑审核记录视图对象。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class EditReviewVO {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String graphId;
|
||||||
|
private String operationType;
|
||||||
|
private String entityId;
|
||||||
|
private String relationId;
|
||||||
|
private String payload;
|
||||||
|
private String status;
|
||||||
|
private String submittedBy;
|
||||||
|
private String reviewedBy;
|
||||||
|
private String reviewComment;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime reviewedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.datamate.knowledgegraph.interfaces.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核通过/拒绝请求。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ReviewActionRequest {
|
||||||
|
|
||||||
|
/** 审核意见(可选) */
|
||||||
|
private String comment;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.datamate.knowledgegraph.interfaces.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.AssertTrue;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交编辑审核请求。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class SubmitReviewRequest {
|
||||||
|
|
||||||
|
private static final String UUID_REGEX =
|
||||||
|
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作类型:CREATE_ENTITY, UPDATE_ENTITY, DELETE_ENTITY,
|
||||||
|
* CREATE_RELATION, UPDATE_RELATION, DELETE_RELATION,
|
||||||
|
* BATCH_DELETE_ENTITY, BATCH_DELETE_RELATION
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "操作类型不能为空")
|
||||||
|
@Pattern(regexp = "^(CREATE|UPDATE|DELETE|BATCH_DELETE)_(ENTITY|RELATION)$",
|
||||||
|
message = "操作类型无效")
|
||||||
|
private String operationType;
|
||||||
|
|
||||||
|
/** 目标实体 ID(实体操作时必填) */
|
||||||
|
private String entityId;
|
||||||
|
|
||||||
|
/** 目标关系 ID(关系操作时必填) */
|
||||||
|
private String relationId;
|
||||||
|
|
||||||
|
/** 变更载荷(JSON 格式的请求体) */
|
||||||
|
private String payload;
|
||||||
|
|
||||||
|
@AssertTrue(message = "UPDATE/DELETE 实体操作必须提供 entityId")
|
||||||
|
private boolean isEntityIdValid() {
|
||||||
|
if (operationType == null) return true;
|
||||||
|
if (operationType.endsWith("_ENTITY") && !operationType.startsWith("CREATE")
|
||||||
|
&& !operationType.startsWith("BATCH")) {
|
||||||
|
return entityId != null && !entityId.isBlank();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@AssertTrue(message = "UPDATE/DELETE 关系操作必须提供 relationId")
|
||||||
|
private boolean isRelationIdValid() {
|
||||||
|
if (operationType == null) return true;
|
||||||
|
if (operationType.endsWith("_RELATION") && !operationType.startsWith("CREATE")
|
||||||
|
&& !operationType.startsWith("BATCH")) {
|
||||||
|
return relationId != null && !relationId.isBlank();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@AssertTrue(message = "CREATE/UPDATE/BATCH_DELETE 操作必须提供 payload")
|
||||||
|
private boolean isPayloadValid() {
|
||||||
|
if (operationType == null) return true;
|
||||||
|
if (operationType.startsWith("CREATE") || operationType.startsWith("UPDATE")
|
||||||
|
|| operationType.startsWith("BATCH_DELETE")) {
|
||||||
|
return payload != null && !payload.isBlank();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,4 +15,6 @@ public class UpdateEntityRequest {
|
|||||||
private List<String> aliases;
|
private List<String> aliases;
|
||||||
|
|
||||||
private Map<String, Object> properties;
|
private Map<String, Object> properties;
|
||||||
|
|
||||||
|
private Double confidence;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.datamate.knowledgegraph.interfaces.rest;
|
||||||
|
|
||||||
|
import com.datamate.common.interfaces.PagedResponse;
|
||||||
|
import com.datamate.knowledgegraph.application.EditReviewService;
|
||||||
|
import com.datamate.knowledgegraph.interfaces.dto.EditReviewVO;
|
||||||
|
import com.datamate.knowledgegraph.interfaces.dto.ReviewActionRequest;
|
||||||
|
import com.datamate.knowledgegraph.interfaces.dto.SubmitReviewRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/knowledge-graph/{graphId}/review")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Validated
|
||||||
|
public class EditReviewController {
|
||||||
|
|
||||||
|
private static final String UUID_REGEX =
|
||||||
|
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$";
|
||||||
|
|
||||||
|
private final EditReviewService reviewService;
|
||||||
|
|
||||||
|
@PostMapping("/submit")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public EditReviewVO submitReview(
|
||||||
|
@PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId,
|
||||||
|
@Valid @RequestBody SubmitReviewRequest request,
|
||||||
|
@RequestHeader(value = "X-User-Id", defaultValue = "anonymous") String userId) {
|
||||||
|
return reviewService.submitReview(graphId, request, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{reviewId}/approve")
|
||||||
|
public EditReviewVO approveReview(
|
||||||
|
@PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId,
|
||||||
|
@PathVariable @Pattern(regexp = UUID_REGEX, message = "reviewId 格式无效") String reviewId,
|
||||||
|
@RequestBody(required = false) ReviewActionRequest request,
|
||||||
|
@RequestHeader(value = "X-User-Id", defaultValue = "anonymous") String userId) {
|
||||||
|
String comment = (request != null) ? request.getComment() : null;
|
||||||
|
return reviewService.approveReview(graphId, reviewId, userId, comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{reviewId}/reject")
|
||||||
|
public EditReviewVO rejectReview(
|
||||||
|
@PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId,
|
||||||
|
@PathVariable @Pattern(regexp = UUID_REGEX, message = "reviewId 格式无效") String reviewId,
|
||||||
|
@RequestBody(required = false) ReviewActionRequest request,
|
||||||
|
@RequestHeader(value = "X-User-Id", defaultValue = "anonymous") String userId) {
|
||||||
|
String comment = (request != null) ? request.getComment() : null;
|
||||||
|
return reviewService.rejectReview(graphId, reviewId, userId, comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/pending")
|
||||||
|
public PagedResponse<EditReviewVO> listPendingReviews(
|
||||||
|
@PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
return reviewService.listPendingReviews(graphId, page, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public PagedResponse<EditReviewVO> listReviews(
|
||||||
|
@PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId,
|
||||||
|
@RequestParam(required = false) String status,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size) {
|
||||||
|
return reviewService.listReviews(graphId, status, page, size);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,4 +119,5 @@ public class GraphEntityController {
|
|||||||
@RequestParam(defaultValue = "50") int limit) {
|
@RequestParam(defaultValue = "50") int limit) {
|
||||||
return entityService.getNeighbors(graphId, entityId, depth, limit);
|
return entityService.getNeighbors(graphId, entityId, depth, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,4 +62,5 @@ public class GraphRelationController {
|
|||||||
@PathVariable @Pattern(regexp = UUID_REGEX, message = "relationId 格式无效") String relationId) {
|
@PathVariable @Pattern(regexp = UUID_REGEX, message = "relationId 格式无效") String relationId) {
|
||||||
relationService.deleteRelation(graphId, relationId);
|
relationService.deleteRelation(graphId, relationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
package com.datamate.knowledgegraph.application;
|
||||||
|
|
||||||
|
import com.datamate.common.infrastructure.exception.BusinessException;
|
||||||
|
import com.datamate.knowledgegraph.domain.model.EditReview;
|
||||||
|
import com.datamate.knowledgegraph.domain.repository.EditReviewRepository;
|
||||||
|
import com.datamate.knowledgegraph.interfaces.dto.EditReviewVO;
|
||||||
|
import com.datamate.knowledgegraph.interfaces.dto.SubmitReviewRequest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class EditReviewServiceTest {
|
||||||
|
|
||||||
|
private static final String GRAPH_ID = "550e8400-e29b-41d4-a716-446655440000";
|
||||||
|
private static final String REVIEW_ID = "660e8400-e29b-41d4-a716-446655440001";
|
||||||
|
private static final String ENTITY_ID = "770e8400-e29b-41d4-a716-446655440002";
|
||||||
|
private static final String USER_ID = "user-1";
|
||||||
|
private static final String REVIEWER_ID = "reviewer-1";
|
||||||
|
private static final String INVALID_GRAPH_ID = "not-a-uuid";
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private EditReviewRepository reviewRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private GraphEntityService entityService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private GraphRelationService relationService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private EditReviewService reviewService;
|
||||||
|
|
||||||
|
private EditReview pendingReview;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
pendingReview = EditReview.builder()
|
||||||
|
.id(REVIEW_ID)
|
||||||
|
.graphId(GRAPH_ID)
|
||||||
|
.operationType("CREATE_ENTITY")
|
||||||
|
.payload("{\"name\":\"TestEntity\",\"type\":\"Dataset\"}")
|
||||||
|
.status("PENDING")
|
||||||
|
.submittedBy(USER_ID)
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// graphId 校验
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void submitReview_invalidGraphId_throwsBusinessException() {
|
||||||
|
SubmitReviewRequest request = new SubmitReviewRequest();
|
||||||
|
request.setOperationType("CREATE_ENTITY");
|
||||||
|
request.setPayload("{}");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> reviewService.submitReview(INVALID_GRAPH_ID, request, USER_ID))
|
||||||
|
.isInstanceOf(BusinessException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approveReview_invalidGraphId_throwsBusinessException() {
|
||||||
|
assertThatThrownBy(() -> reviewService.approveReview(INVALID_GRAPH_ID, REVIEW_ID, REVIEWER_ID, null))
|
||||||
|
.isInstanceOf(BusinessException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// submitReview
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void submitReview_success() {
|
||||||
|
SubmitReviewRequest request = new SubmitReviewRequest();
|
||||||
|
request.setOperationType("CREATE_ENTITY");
|
||||||
|
request.setPayload("{\"name\":\"NewEntity\",\"type\":\"Dataset\"}");
|
||||||
|
|
||||||
|
when(reviewRepository.save(any(EditReview.class))).thenReturn(pendingReview);
|
||||||
|
|
||||||
|
EditReviewVO result = reviewService.submitReview(GRAPH_ID, request, USER_ID);
|
||||||
|
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
assertThat(result.getStatus()).isEqualTo("PENDING");
|
||||||
|
assertThat(result.getOperationType()).isEqualTo("CREATE_ENTITY");
|
||||||
|
verify(reviewRepository).save(any(EditReview.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void submitReview_withEntityId() {
|
||||||
|
SubmitReviewRequest request = new SubmitReviewRequest();
|
||||||
|
request.setOperationType("UPDATE_ENTITY");
|
||||||
|
request.setEntityId(ENTITY_ID);
|
||||||
|
request.setPayload("{\"name\":\"Updated\"}");
|
||||||
|
|
||||||
|
EditReview savedReview = EditReview.builder()
|
||||||
|
.id(REVIEW_ID)
|
||||||
|
.graphId(GRAPH_ID)
|
||||||
|
.operationType("UPDATE_ENTITY")
|
||||||
|
.entityId(ENTITY_ID)
|
||||||
|
.payload("{\"name\":\"Updated\"}")
|
||||||
|
.status("PENDING")
|
||||||
|
.submittedBy(USER_ID)
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
when(reviewRepository.save(any(EditReview.class))).thenReturn(savedReview);
|
||||||
|
|
||||||
|
EditReviewVO result = reviewService.submitReview(GRAPH_ID, request, USER_ID);
|
||||||
|
|
||||||
|
assertThat(result.getEntityId()).isEqualTo(ENTITY_ID);
|
||||||
|
assertThat(result.getOperationType()).isEqualTo("UPDATE_ENTITY");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// approveReview
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approveReview_success_appliesChange() {
|
||||||
|
when(reviewRepository.findById(REVIEW_ID, GRAPH_ID))
|
||||||
|
.thenReturn(Optional.of(pendingReview));
|
||||||
|
when(reviewRepository.save(any(EditReview.class))).thenReturn(pendingReview);
|
||||||
|
|
||||||
|
EditReviewVO result = reviewService.approveReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, "LGTM");
|
||||||
|
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
assertThat(pendingReview.getStatus()).isEqualTo("APPROVED");
|
||||||
|
assertThat(pendingReview.getReviewedBy()).isEqualTo(REVIEWER_ID);
|
||||||
|
assertThat(pendingReview.getReviewComment()).isEqualTo("LGTM");
|
||||||
|
assertThat(pendingReview.getReviewedAt()).isNotNull();
|
||||||
|
|
||||||
|
// Verify applyChange was called (createEntity for CREATE_ENTITY)
|
||||||
|
verify(entityService).createEntity(eq(GRAPH_ID), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approveReview_notFound_throwsBusinessException() {
|
||||||
|
when(reviewRepository.findById(REVIEW_ID, GRAPH_ID))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> reviewService.approveReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, null))
|
||||||
|
.isInstanceOf(BusinessException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approveReview_alreadyProcessed_throwsBusinessException() {
|
||||||
|
pendingReview.setStatus("APPROVED");
|
||||||
|
|
||||||
|
when(reviewRepository.findById(REVIEW_ID, GRAPH_ID))
|
||||||
|
.thenReturn(Optional.of(pendingReview));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> reviewService.approveReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, null))
|
||||||
|
.isInstanceOf(BusinessException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approveReview_deleteEntity_appliesChange() {
|
||||||
|
pendingReview.setOperationType("DELETE_ENTITY");
|
||||||
|
pendingReview.setEntityId(ENTITY_ID);
|
||||||
|
pendingReview.setPayload(null);
|
||||||
|
|
||||||
|
when(reviewRepository.findById(REVIEW_ID, GRAPH_ID))
|
||||||
|
.thenReturn(Optional.of(pendingReview));
|
||||||
|
when(reviewRepository.save(any(EditReview.class))).thenReturn(pendingReview);
|
||||||
|
|
||||||
|
reviewService.approveReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, null);
|
||||||
|
|
||||||
|
verify(entityService).deleteEntity(GRAPH_ID, ENTITY_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approveReview_updateEntity_appliesChange() {
|
||||||
|
pendingReview.setOperationType("UPDATE_ENTITY");
|
||||||
|
pendingReview.setEntityId(ENTITY_ID);
|
||||||
|
pendingReview.setPayload("{\"name\":\"Updated\"}");
|
||||||
|
|
||||||
|
when(reviewRepository.findById(REVIEW_ID, GRAPH_ID))
|
||||||
|
.thenReturn(Optional.of(pendingReview));
|
||||||
|
when(reviewRepository.save(any(EditReview.class))).thenReturn(pendingReview);
|
||||||
|
|
||||||
|
reviewService.approveReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, null);
|
||||||
|
|
||||||
|
verify(entityService).updateEntity(eq(GRAPH_ID), eq(ENTITY_ID), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approveReview_createRelation_appliesChange() {
|
||||||
|
pendingReview.setOperationType("CREATE_RELATION");
|
||||||
|
pendingReview.setPayload("{\"sourceEntityId\":\"a\",\"targetEntityId\":\"b\",\"relationType\":\"HAS_FIELD\"}");
|
||||||
|
|
||||||
|
when(reviewRepository.findById(REVIEW_ID, GRAPH_ID))
|
||||||
|
.thenReturn(Optional.of(pendingReview));
|
||||||
|
when(reviewRepository.save(any(EditReview.class))).thenReturn(pendingReview);
|
||||||
|
|
||||||
|
reviewService.approveReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, null);
|
||||||
|
|
||||||
|
verify(relationService).createRelation(eq(GRAPH_ID), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approveReview_invalidPayload_throwsBusinessException() {
|
||||||
|
pendingReview.setOperationType("CREATE_ENTITY");
|
||||||
|
pendingReview.setPayload("not valid json {{");
|
||||||
|
|
||||||
|
when(reviewRepository.findById(REVIEW_ID, GRAPH_ID))
|
||||||
|
.thenReturn(Optional.of(pendingReview));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> reviewService.approveReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, null))
|
||||||
|
.isInstanceOf(BusinessException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approveReview_batchDeleteEntity_appliesChange() {
|
||||||
|
pendingReview.setOperationType("BATCH_DELETE_ENTITY");
|
||||||
|
pendingReview.setPayload("{\"ids\":[\"id-1\",\"id-2\",\"id-3\"]}");
|
||||||
|
|
||||||
|
when(reviewRepository.findById(REVIEW_ID, GRAPH_ID))
|
||||||
|
.thenReturn(Optional.of(pendingReview));
|
||||||
|
when(reviewRepository.save(any(EditReview.class))).thenReturn(pendingReview);
|
||||||
|
|
||||||
|
reviewService.approveReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, null);
|
||||||
|
|
||||||
|
verify(entityService).batchDeleteEntities(eq(GRAPH_ID), eq(List.of("id-1", "id-2", "id-3")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approveReview_batchDeleteRelation_appliesChange() {
|
||||||
|
pendingReview.setOperationType("BATCH_DELETE_RELATION");
|
||||||
|
pendingReview.setPayload("{\"ids\":[\"rel-1\",\"rel-2\"]}");
|
||||||
|
|
||||||
|
when(reviewRepository.findById(REVIEW_ID, GRAPH_ID))
|
||||||
|
.thenReturn(Optional.of(pendingReview));
|
||||||
|
when(reviewRepository.save(any(EditReview.class))).thenReturn(pendingReview);
|
||||||
|
|
||||||
|
reviewService.approveReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, null);
|
||||||
|
|
||||||
|
verify(relationService).batchDeleteRelations(eq(GRAPH_ID), eq(List.of("rel-1", "rel-2")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// rejectReview
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectReview_success() {
|
||||||
|
when(reviewRepository.findById(REVIEW_ID, GRAPH_ID))
|
||||||
|
.thenReturn(Optional.of(pendingReview));
|
||||||
|
when(reviewRepository.save(any(EditReview.class))).thenReturn(pendingReview);
|
||||||
|
|
||||||
|
EditReviewVO result = reviewService.rejectReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, "不合适");
|
||||||
|
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
assertThat(pendingReview.getStatus()).isEqualTo("REJECTED");
|
||||||
|
assertThat(pendingReview.getReviewedBy()).isEqualTo(REVIEWER_ID);
|
||||||
|
assertThat(pendingReview.getReviewComment()).isEqualTo("不合适");
|
||||||
|
assertThat(pendingReview.getReviewedAt()).isNotNull();
|
||||||
|
|
||||||
|
// Verify no change was applied
|
||||||
|
verifyNoInteractions(entityService);
|
||||||
|
verifyNoInteractions(relationService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectReview_notFound_throwsBusinessException() {
|
||||||
|
when(reviewRepository.findById(REVIEW_ID, GRAPH_ID))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> reviewService.rejectReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, null))
|
||||||
|
.isInstanceOf(BusinessException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectReview_alreadyProcessed_throwsBusinessException() {
|
||||||
|
pendingReview.setStatus("REJECTED");
|
||||||
|
|
||||||
|
when(reviewRepository.findById(REVIEW_ID, GRAPH_ID))
|
||||||
|
.thenReturn(Optional.of(pendingReview));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> reviewService.rejectReview(GRAPH_ID, REVIEW_ID, REVIEWER_ID, null))
|
||||||
|
.isInstanceOf(BusinessException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// listPendingReviews
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listPendingReviews_returnsPagedResult() {
|
||||||
|
when(reviewRepository.findPendingByGraphId(GRAPH_ID, 0L, 20))
|
||||||
|
.thenReturn(List.of(pendingReview));
|
||||||
|
when(reviewRepository.countPendingByGraphId(GRAPH_ID)).thenReturn(1L);
|
||||||
|
|
||||||
|
var result = reviewService.listPendingReviews(GRAPH_ID, 0, 20);
|
||||||
|
|
||||||
|
assertThat(result.getContent()).hasSize(1);
|
||||||
|
assertThat(result.getTotalElements()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listPendingReviews_clampsPageSize() {
|
||||||
|
when(reviewRepository.findPendingByGraphId(GRAPH_ID, 0L, 200))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
when(reviewRepository.countPendingByGraphId(GRAPH_ID)).thenReturn(0L);
|
||||||
|
|
||||||
|
reviewService.listPendingReviews(GRAPH_ID, 0, 999);
|
||||||
|
|
||||||
|
verify(reviewRepository).findPendingByGraphId(GRAPH_ID, 0L, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listPendingReviews_negativePage_clampedToZero() {
|
||||||
|
when(reviewRepository.findPendingByGraphId(GRAPH_ID, 0L, 20))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
when(reviewRepository.countPendingByGraphId(GRAPH_ID)).thenReturn(0L);
|
||||||
|
|
||||||
|
var result = reviewService.listPendingReviews(GRAPH_ID, -1, 20);
|
||||||
|
|
||||||
|
assertThat(result.getPage()).isEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// listReviews
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listReviews_withStatusFilter() {
|
||||||
|
when(reviewRepository.findByGraphId(GRAPH_ID, "APPROVED", 0L, 20))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
when(reviewRepository.countByGraphId(GRAPH_ID, "APPROVED")).thenReturn(0L);
|
||||||
|
|
||||||
|
var result = reviewService.listReviews(GRAPH_ID, "APPROVED", 0, 20);
|
||||||
|
|
||||||
|
assertThat(result.getContent()).isEmpty();
|
||||||
|
verify(reviewRepository).findByGraphId(GRAPH_ID, "APPROVED", 0L, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listReviews_withoutStatusFilter() {
|
||||||
|
when(reviewRepository.findByGraphId(GRAPH_ID, null, 0L, 20))
|
||||||
|
.thenReturn(List.of(pendingReview));
|
||||||
|
when(reviewRepository.countByGraphId(GRAPH_ID, null)).thenReturn(1L);
|
||||||
|
|
||||||
|
var result = reviewService.listReviews(GRAPH_ID, null, 0, 20);
|
||||||
|
|
||||||
|
assertThat(result.getContent()).hasSize(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package com.datamate.knowledgegraph.interfaces.rest;
|
||||||
|
|
||||||
|
import com.datamate.common.infrastructure.exception.BusinessException;
|
||||||
|
import com.datamate.common.interfaces.PagedResponse;
|
||||||
|
import com.datamate.knowledgegraph.application.EditReviewService;
|
||||||
|
import com.datamate.knowledgegraph.infrastructure.exception.KnowledgeGraphErrorCode;
|
||||||
|
import com.datamate.knowledgegraph.interfaces.dto.EditReviewVO;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class EditReviewControllerTest {
|
||||||
|
|
||||||
|
private static final String GRAPH_ID = "550e8400-e29b-41d4-a716-446655440000";
|
||||||
|
private static final String REVIEW_ID = "660e8400-e29b-41d4-a716-446655440001";
|
||||||
|
private static final String ENTITY_ID = "770e8400-e29b-41d4-a716-446655440002";
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private EditReviewService reviewService;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private EditReviewController controller;
|
||||||
|
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||||
|
objectMapper = new ObjectMapper();
|
||||||
|
objectMapper.registerModule(new JavaTimeModule());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// POST /knowledge-graph/{graphId}/review/submit
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void submitReview_success() throws Exception {
|
||||||
|
EditReviewVO vo = buildReviewVO("PENDING");
|
||||||
|
when(reviewService.submitReview(eq(GRAPH_ID), any(), eq("user-1")))
|
||||||
|
.thenReturn(vo);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/knowledge-graph/{graphId}/review/submit", GRAPH_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.header("X-User-Id", "user-1")
|
||||||
|
.content(objectMapper.writeValueAsString(Map.of(
|
||||||
|
"operationType", "CREATE_ENTITY",
|
||||||
|
"payload", "{\"name\":\"Test\",\"type\":\"Dataset\"}"
|
||||||
|
))))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.id").value(REVIEW_ID))
|
||||||
|
.andExpect(jsonPath("$.status").value("PENDING"))
|
||||||
|
.andExpect(jsonPath("$.operationType").value("CREATE_ENTITY"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void submitReview_delegatesToService() throws Exception {
|
||||||
|
EditReviewVO vo = buildReviewVO("PENDING");
|
||||||
|
when(reviewService.submitReview(eq(GRAPH_ID), any(), eq("user-1")))
|
||||||
|
.thenReturn(vo);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/knowledge-graph/{graphId}/review/submit", GRAPH_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.header("X-User-Id", "user-1")
|
||||||
|
.content(objectMapper.writeValueAsString(Map.of(
|
||||||
|
"operationType", "DELETE_ENTITY",
|
||||||
|
"entityId", ENTITY_ID
|
||||||
|
))))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
|
||||||
|
verify(reviewService).submitReview(eq(GRAPH_ID), any(), eq("user-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void submitReview_defaultUserId_whenHeaderMissing() throws Exception {
|
||||||
|
EditReviewVO vo = buildReviewVO("PENDING");
|
||||||
|
when(reviewService.submitReview(eq(GRAPH_ID), any(), eq("anonymous")))
|
||||||
|
.thenReturn(vo);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/knowledge-graph/{graphId}/review/submit", GRAPH_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(Map.of(
|
||||||
|
"operationType", "CREATE_ENTITY",
|
||||||
|
"payload", "{\"name\":\"Test\"}"
|
||||||
|
))))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
|
||||||
|
verify(reviewService).submitReview(eq(GRAPH_ID), any(), eq("anonymous"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// POST /knowledge-graph/{graphId}/review/{reviewId}/approve
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approveReview_success() throws Exception {
|
||||||
|
EditReviewVO vo = buildReviewVO("APPROVED");
|
||||||
|
when(reviewService.approveReview(eq(GRAPH_ID), eq(REVIEW_ID), eq("reviewer-1"), isNull()))
|
||||||
|
.thenReturn(vo);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/knowledge-graph/{graphId}/review/{reviewId}/approve", GRAPH_ID, REVIEW_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.header("X-User-Id", "reviewer-1"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.status").value("APPROVED"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approveReview_withComment() throws Exception {
|
||||||
|
EditReviewVO vo = buildReviewVO("APPROVED");
|
||||||
|
when(reviewService.approveReview(eq(GRAPH_ID), eq(REVIEW_ID), eq("reviewer-1"), eq("LGTM")))
|
||||||
|
.thenReturn(vo);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/knowledge-graph/{graphId}/review/{reviewId}/approve", GRAPH_ID, REVIEW_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.header("X-User-Id", "reviewer-1")
|
||||||
|
.content(objectMapper.writeValueAsString(Map.of("comment", "LGTM"))))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
verify(reviewService).approveReview(GRAPH_ID, REVIEW_ID, "reviewer-1", "LGTM");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// POST /knowledge-graph/{graphId}/review/{reviewId}/reject
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectReview_success() throws Exception {
|
||||||
|
EditReviewVO vo = buildReviewVO("REJECTED");
|
||||||
|
when(reviewService.rejectReview(eq(GRAPH_ID), eq(REVIEW_ID), eq("reviewer-1"), eq("不合适")))
|
||||||
|
.thenReturn(vo);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/knowledge-graph/{graphId}/review/{reviewId}/reject", GRAPH_ID, REVIEW_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.header("X-User-Id", "reviewer-1")
|
||||||
|
.content(objectMapper.writeValueAsString(Map.of("comment", "不合适"))))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.status").value("REJECTED"));
|
||||||
|
|
||||||
|
verify(reviewService).rejectReview(GRAPH_ID, REVIEW_ID, "reviewer-1", "不合适");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /knowledge-graph/{graphId}/review/pending
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listPendingReviews_success() throws Exception {
|
||||||
|
EditReviewVO vo = buildReviewVO("PENDING");
|
||||||
|
PagedResponse<EditReviewVO> page = PagedResponse.of(List.of(vo), 0, 1, 1);
|
||||||
|
when(reviewService.listPendingReviews(GRAPH_ID, 0, 20)).thenReturn(page);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/knowledge-graph/{graphId}/review/pending", GRAPH_ID))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.content").isArray())
|
||||||
|
.andExpect(jsonPath("$.content[0].id").value(REVIEW_ID))
|
||||||
|
.andExpect(jsonPath("$.totalElements").value(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listPendingReviews_customPageSize() throws Exception {
|
||||||
|
PagedResponse<EditReviewVO> page = PagedResponse.of(List.of(), 0, 0, 0);
|
||||||
|
when(reviewService.listPendingReviews(GRAPH_ID, 1, 10)).thenReturn(page);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/knowledge-graph/{graphId}/review/pending", GRAPH_ID)
|
||||||
|
.param("page", "1")
|
||||||
|
.param("size", "10"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
verify(reviewService).listPendingReviews(GRAPH_ID, 1, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /knowledge-graph/{graphId}/review
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listReviews_withStatusFilter() throws Exception {
|
||||||
|
PagedResponse<EditReviewVO> page = PagedResponse.of(List.of(), 0, 0, 0);
|
||||||
|
when(reviewService.listReviews(GRAPH_ID, "APPROVED", 0, 20)).thenReturn(page);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/knowledge-graph/{graphId}/review", GRAPH_ID)
|
||||||
|
.param("status", "APPROVED"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.content").isEmpty());
|
||||||
|
|
||||||
|
verify(reviewService).listReviews(GRAPH_ID, "APPROVED", 0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listReviews_withoutStatusFilter() throws Exception {
|
||||||
|
EditReviewVO vo = buildReviewVO("PENDING");
|
||||||
|
PagedResponse<EditReviewVO> page = PagedResponse.of(List.of(vo), 0, 1, 1);
|
||||||
|
when(reviewService.listReviews(GRAPH_ID, null, 0, 20)).thenReturn(page);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/knowledge-graph/{graphId}/review", GRAPH_ID))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.content").isArray())
|
||||||
|
.andExpect(jsonPath("$.content[0].id").value(REVIEW_ID));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private EditReviewVO buildReviewVO(String status) {
|
||||||
|
return EditReviewVO.builder()
|
||||||
|
.id(REVIEW_ID)
|
||||||
|
.graphId(GRAPH_ID)
|
||||||
|
.operationType("CREATE_ENTITY")
|
||||||
|
.payload("{\"name\":\"Test\",\"type\":\"Dataset\"}")
|
||||||
|
.status(status)
|
||||||
|
.submittedBy("user-1")
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,26 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { Card, Input, Select, Button, Tag, Space, Empty, Tabs, message } from "antd";
|
import { Card, Input, Select, Button, Tag, Space, Empty, Tabs, Switch, message, Popconfirm } from "antd";
|
||||||
import { Network, RotateCcw } from "lucide-react";
|
import { Network, RotateCcw, Plus, Link2, Trash2 } from "lucide-react";
|
||||||
import { useSearchParams } from "react-router";
|
import { useSearchParams } from "react-router";
|
||||||
|
import { useAppSelector } from "@/store/hooks";
|
||||||
|
import { hasPermission, PermissionCodes } from "@/auth/permissions";
|
||||||
import GraphCanvas from "../components/GraphCanvas";
|
import GraphCanvas from "../components/GraphCanvas";
|
||||||
import SearchPanel from "../components/SearchPanel";
|
import SearchPanel from "../components/SearchPanel";
|
||||||
import QueryBuilder from "../components/QueryBuilder";
|
import QueryBuilder from "../components/QueryBuilder";
|
||||||
import NodeDetail from "../components/NodeDetail";
|
import NodeDetail from "../components/NodeDetail";
|
||||||
import RelationDetail from "../components/RelationDetail";
|
import RelationDetail from "../components/RelationDetail";
|
||||||
|
import EntityEditForm from "../components/EntityEditForm";
|
||||||
|
import RelationEditForm from "../components/RelationEditForm";
|
||||||
|
import ReviewPanel from "../components/ReviewPanel";
|
||||||
import useGraphData from "../hooks/useGraphData";
|
import useGraphData from "../hooks/useGraphData";
|
||||||
import useGraphLayout, { LAYOUT_OPTIONS } from "../hooks/useGraphLayout";
|
import useGraphLayout, { LAYOUT_OPTIONS } from "../hooks/useGraphLayout";
|
||||||
|
import type { GraphEntity, RelationVO } from "../knowledge-graph.model";
|
||||||
import {
|
import {
|
||||||
ENTITY_TYPE_COLORS,
|
ENTITY_TYPE_COLORS,
|
||||||
DEFAULT_ENTITY_COLOR,
|
DEFAULT_ENTITY_COLOR,
|
||||||
ENTITY_TYPE_LABELS,
|
ENTITY_TYPE_LABELS,
|
||||||
} from "../knowledge-graph.const";
|
} from "../knowledge-graph.const";
|
||||||
|
import * as api from "../knowledge-graph.api";
|
||||||
|
|
||||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
@@ -22,6 +29,10 @@ export default function KnowledgeGraphPage() {
|
|||||||
const [graphId, setGraphId] = useState(() => params.get("graphId") ?? "");
|
const [graphId, setGraphId] = useState(() => params.get("graphId") ?? "");
|
||||||
const [graphIdInput, setGraphIdInput] = useState(() => params.get("graphId") ?? "");
|
const [graphIdInput, setGraphIdInput] = useState(() => params.get("graphId") ?? "");
|
||||||
|
|
||||||
|
// Permission check
|
||||||
|
const permissions = useAppSelector((state) => state.auth.permissions);
|
||||||
|
const canWrite = hasPermission(permissions, PermissionCodes.knowledgeGraphWrite);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
graphData,
|
graphData,
|
||||||
loading,
|
loading,
|
||||||
@@ -38,12 +49,26 @@ export default function KnowledgeGraphPage() {
|
|||||||
|
|
||||||
const { layoutType, setLayoutType } = useGraphLayout();
|
const { layoutType, setLayoutType } = useGraphLayout();
|
||||||
|
|
||||||
|
// Edit mode (only allowed with write permission)
|
||||||
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
|
||||||
// Detail panel state
|
// Detail panel state
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||||
const [nodeDetailOpen, setNodeDetailOpen] = useState(false);
|
const [nodeDetailOpen, setNodeDetailOpen] = useState(false);
|
||||||
const [relationDetailOpen, setRelationDetailOpen] = useState(false);
|
const [relationDetailOpen, setRelationDetailOpen] = useState(false);
|
||||||
|
|
||||||
|
// Edit form state
|
||||||
|
const [entityFormOpen, setEntityFormOpen] = useState(false);
|
||||||
|
const [editingEntity, setEditingEntity] = useState<GraphEntity | null>(null);
|
||||||
|
const [relationFormOpen, setRelationFormOpen] = useState(false);
|
||||||
|
const [editingRelation, setEditingRelation] = useState<RelationVO | null>(null);
|
||||||
|
const [defaultRelationSourceId, setDefaultRelationSourceId] = useState<string | undefined>();
|
||||||
|
|
||||||
|
// Batch selection state
|
||||||
|
const [selectedNodeIds, setSelectedNodeIds] = useState<string[]>([]);
|
||||||
|
const [selectedEdgeIds, setSelectedEdgeIds] = useState<string[]>([]);
|
||||||
|
|
||||||
// Load graph when graphId changes
|
// Load graph when graphId changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (graphId && UUID_REGEX.test(graphId)) {
|
if (graphId && UUID_REGEX.test(graphId)) {
|
||||||
@@ -110,7 +135,6 @@ export default function KnowledgeGraphPage() {
|
|||||||
const handleSearchResultClick = useCallback(
|
const handleSearchResultClick = useCallback(
|
||||||
(entityId: string) => {
|
(entityId: string) => {
|
||||||
handleNodeClick(entityId);
|
handleNodeClick(entityId);
|
||||||
// If the entity is not in the current graph, expand it
|
|
||||||
if (!graphData.nodes.find((n) => n.id === entityId) && graphId) {
|
if (!graphData.nodes.find((n) => n.id === entityId) && graphId) {
|
||||||
expandNode(graphId, entityId);
|
expandNode(graphId, entityId);
|
||||||
}
|
}
|
||||||
@@ -124,9 +148,123 @@ export default function KnowledgeGraphPage() {
|
|||||||
setNodeDetailOpen(false);
|
setNodeDetailOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectionChange = useCallback((nodeIds: string[], edgeIds: string[]) => {
|
||||||
|
setSelectedNodeIds(nodeIds);
|
||||||
|
setSelectedEdgeIds(edgeIds);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- Edit handlers ----
|
||||||
|
|
||||||
|
const refreshGraph = useCallback(() => {
|
||||||
|
if (graphId) {
|
||||||
|
loadInitialData(graphId);
|
||||||
|
}
|
||||||
|
}, [graphId, loadInitialData]);
|
||||||
|
|
||||||
|
const handleEditEntity = useCallback((entity: GraphEntity) => {
|
||||||
|
setEditingEntity(entity);
|
||||||
|
setEntityFormOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateEntity = useCallback(() => {
|
||||||
|
setEditingEntity(null);
|
||||||
|
setEntityFormOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteEntity = useCallback(
|
||||||
|
async (entityId: string) => {
|
||||||
|
if (!graphId) return;
|
||||||
|
try {
|
||||||
|
await api.submitReview(graphId, {
|
||||||
|
operationType: "DELETE_ENTITY",
|
||||||
|
entityId,
|
||||||
|
});
|
||||||
|
message.success("实体删除已提交审核");
|
||||||
|
setNodeDetailOpen(false);
|
||||||
|
setSelectedNodeId(null);
|
||||||
|
refreshGraph();
|
||||||
|
} catch {
|
||||||
|
message.error("提交实体删除审核失败");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[graphId, refreshGraph]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEditRelation = useCallback((relation: RelationVO) => {
|
||||||
|
setEditingRelation(relation);
|
||||||
|
setDefaultRelationSourceId(undefined);
|
||||||
|
setRelationFormOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateRelation = useCallback((sourceEntityId?: string) => {
|
||||||
|
setEditingRelation(null);
|
||||||
|
setDefaultRelationSourceId(sourceEntityId);
|
||||||
|
setRelationFormOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteRelation = useCallback(
|
||||||
|
async (relationId: string) => {
|
||||||
|
if (!graphId) return;
|
||||||
|
try {
|
||||||
|
await api.submitReview(graphId, {
|
||||||
|
operationType: "DELETE_RELATION",
|
||||||
|
relationId,
|
||||||
|
});
|
||||||
|
message.success("关系删除已提交审核");
|
||||||
|
setRelationDetailOpen(false);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
|
refreshGraph();
|
||||||
|
} catch {
|
||||||
|
message.error("提交关系删除审核失败");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[graphId, refreshGraph]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEntityFormSuccess = useCallback(() => {
|
||||||
|
refreshGraph();
|
||||||
|
}, [refreshGraph]);
|
||||||
|
|
||||||
|
const handleRelationFormSuccess = useCallback(() => {
|
||||||
|
refreshGraph();
|
||||||
|
}, [refreshGraph]);
|
||||||
|
|
||||||
|
// ---- Batch operations ----
|
||||||
|
|
||||||
|
const handleBatchDeleteNodes = useCallback(async () => {
|
||||||
|
if (!graphId || selectedNodeIds.length === 0) return;
|
||||||
|
try {
|
||||||
|
await api.submitReview(graphId, {
|
||||||
|
operationType: "BATCH_DELETE_ENTITY",
|
||||||
|
payload: JSON.stringify({ ids: selectedNodeIds }),
|
||||||
|
});
|
||||||
|
message.success("批量删除实体已提交审核");
|
||||||
|
setSelectedNodeIds([]);
|
||||||
|
refreshGraph();
|
||||||
|
} catch {
|
||||||
|
message.error("提交批量删除实体审核失败");
|
||||||
|
}
|
||||||
|
}, [graphId, selectedNodeIds, refreshGraph]);
|
||||||
|
|
||||||
|
const handleBatchDeleteEdges = useCallback(async () => {
|
||||||
|
if (!graphId || selectedEdgeIds.length === 0) return;
|
||||||
|
try {
|
||||||
|
await api.submitReview(graphId, {
|
||||||
|
operationType: "BATCH_DELETE_RELATION",
|
||||||
|
payload: JSON.stringify({ ids: selectedEdgeIds }),
|
||||||
|
});
|
||||||
|
message.success("批量删除关系已提交审核");
|
||||||
|
setSelectedEdgeIds([]);
|
||||||
|
refreshGraph();
|
||||||
|
} catch {
|
||||||
|
message.error("提交批量删除关系审核失败");
|
||||||
|
}
|
||||||
|
}, [graphId, selectedEdgeIds, refreshGraph]);
|
||||||
|
|
||||||
const hasGraph = graphId && UUID_REGEX.test(graphId);
|
const hasGraph = graphId && UUID_REGEX.test(graphId);
|
||||||
const nodeCount = graphData.nodes.length;
|
const nodeCount = graphData.nodes.length;
|
||||||
const edgeCount = graphData.edges.length;
|
const edgeCount = graphData.edges.length;
|
||||||
|
const hasBatchSelection = editMode && (selectedNodeIds.length > 1 || selectedEdgeIds.length > 1);
|
||||||
|
|
||||||
// Collect unique entity types in current graph for legend
|
// Collect unique entity types in current graph for legend
|
||||||
const entityTypes = [...new Set(graphData.nodes.map((n) => n.data.type))].sort();
|
const entityTypes = [...new Set(graphData.nodes.map((n) => n.data.type))].sort();
|
||||||
@@ -139,6 +277,16 @@ export default function KnowledgeGraphPage() {
|
|||||||
<Network className="w-5 h-5" />
|
<Network className="w-5 h-5" />
|
||||||
知识图谱浏览器
|
知识图谱浏览器
|
||||||
</h1>
|
</h1>
|
||||||
|
{hasGraph && canWrite && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-500">编辑模式</span>
|
||||||
|
<Switch
|
||||||
|
checked={editMode}
|
||||||
|
onChange={setEditMode}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Graph ID Input + Controls */}
|
{/* Graph ID Input + Controls */}
|
||||||
@@ -176,6 +324,62 @@ export default function KnowledgeGraphPage() {
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Edit mode toolbar */}
|
||||||
|
{hasGraph && editMode && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<Plus className="w-3.5 h-3.5" />}
|
||||||
|
onClick={handleCreateEntity}
|
||||||
|
>
|
||||||
|
创建实体
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<Link2 className="w-3.5 h-3.5" />}
|
||||||
|
onClick={() => handleCreateRelation()}
|
||||||
|
>
|
||||||
|
创建关系
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Batch operations toolbar */}
|
||||||
|
{hasBatchSelection && (
|
||||||
|
<>
|
||||||
|
{selectedNodeIds.length > 1 && (
|
||||||
|
<Popconfirm
|
||||||
|
title={`确认批量删除 ${selectedNodeIds.length} 个实体?`}
|
||||||
|
description="删除后关联的关系也会被移除"
|
||||||
|
onConfirm={handleBatchDeleteNodes}
|
||||||
|
okText="确认"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||||
|
>
|
||||||
|
批量删除实体 ({selectedNodeIds.length})
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
{selectedEdgeIds.length > 1 && (
|
||||||
|
<Popconfirm
|
||||||
|
title={`确认批量删除 ${selectedEdgeIds.length} 条关系?`}
|
||||||
|
onConfirm={handleBatchDeleteEdges}
|
||||||
|
okText="确认"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||||
|
>
|
||||||
|
批量删除关系 ({selectedEdgeIds.length})
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
@@ -223,6 +427,11 @@ export default function KnowledgeGraphPage() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "review",
|
||||||
|
label: "审核",
|
||||||
|
children: <ReviewPanel graphId={graphId} />,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -236,10 +445,12 @@ export default function KnowledgeGraphPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
layoutType={layoutType}
|
layoutType={layoutType}
|
||||||
highlightedNodeIds={highlightedNodeIds}
|
highlightedNodeIds={highlightedNodeIds}
|
||||||
|
editMode={editMode}
|
||||||
onNodeClick={handleNodeClick}
|
onNodeClick={handleNodeClick}
|
||||||
onEdgeClick={handleEdgeClick}
|
onEdgeClick={handleEdgeClick}
|
||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onCanvasClick={handleCanvasClick}
|
onCanvasClick={handleCanvasClick}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex items-center justify-center">
|
<div className="h-full flex items-center justify-center">
|
||||||
@@ -257,17 +468,41 @@ export default function KnowledgeGraphPage() {
|
|||||||
graphId={graphId}
|
graphId={graphId}
|
||||||
entityId={selectedNodeId}
|
entityId={selectedNodeId}
|
||||||
open={nodeDetailOpen}
|
open={nodeDetailOpen}
|
||||||
|
editMode={editMode}
|
||||||
onClose={() => setNodeDetailOpen(false)}
|
onClose={() => setNodeDetailOpen(false)}
|
||||||
onExpandNode={handleExpandNode}
|
onExpandNode={handleExpandNode}
|
||||||
onRelationClick={handleRelationClick}
|
onRelationClick={handleRelationClick}
|
||||||
onEntityNavigate={handleEntityNavigate}
|
onEntityNavigate={handleEntityNavigate}
|
||||||
|
onEditEntity={handleEditEntity}
|
||||||
|
onDeleteEntity={handleDeleteEntity}
|
||||||
|
onCreateRelation={handleCreateRelation}
|
||||||
/>
|
/>
|
||||||
<RelationDetail
|
<RelationDetail
|
||||||
graphId={graphId}
|
graphId={graphId}
|
||||||
relationId={selectedEdgeId}
|
relationId={selectedEdgeId}
|
||||||
open={relationDetailOpen}
|
open={relationDetailOpen}
|
||||||
|
editMode={editMode}
|
||||||
onClose={() => setRelationDetailOpen(false)}
|
onClose={() => setRelationDetailOpen(false)}
|
||||||
onEntityNavigate={handleEntityNavigate}
|
onEntityNavigate={handleEntityNavigate}
|
||||||
|
onEditRelation={handleEditRelation}
|
||||||
|
onDeleteRelation={handleDeleteRelation}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit forms */}
|
||||||
|
<EntityEditForm
|
||||||
|
graphId={graphId}
|
||||||
|
entity={editingEntity}
|
||||||
|
open={entityFormOpen}
|
||||||
|
onClose={() => setEntityFormOpen(false)}
|
||||||
|
onSuccess={handleEntityFormSuccess}
|
||||||
|
/>
|
||||||
|
<RelationEditForm
|
||||||
|
graphId={graphId}
|
||||||
|
relation={editingRelation}
|
||||||
|
open={relationFormOpen}
|
||||||
|
onClose={() => setRelationFormOpen(false)}
|
||||||
|
onSuccess={handleRelationFormSuccess}
|
||||||
|
defaultSourceId={defaultRelationSourceId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
143
frontend/src/pages/KnowledgeGraph/components/EntityEditForm.tsx
Normal file
143
frontend/src/pages/KnowledgeGraph/components/EntityEditForm.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { Modal, Form, Input, Select, InputNumber, message } from "antd";
|
||||||
|
import type { GraphEntity } from "../knowledge-graph.model";
|
||||||
|
import { ENTITY_TYPES, ENTITY_TYPE_LABELS } from "../knowledge-graph.const";
|
||||||
|
import * as api from "../knowledge-graph.api";
|
||||||
|
|
||||||
|
interface EntityEditFormProps {
|
||||||
|
graphId: string;
|
||||||
|
entity?: GraphEntity | null;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EntityEditForm({
|
||||||
|
graphId,
|
||||||
|
entity,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: EntityEditFormProps) {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const isEdit = !!entity;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && entity) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: entity.name,
|
||||||
|
type: entity.type,
|
||||||
|
description: entity.description ?? "",
|
||||||
|
aliases: entity.aliases?.join(", ") ?? "",
|
||||||
|
confidence: entity.confidence ?? 1.0,
|
||||||
|
});
|
||||||
|
} else if (open) {
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
}, [open, entity, form]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
let values;
|
||||||
|
try {
|
||||||
|
values = await form.validateFields();
|
||||||
|
} catch {
|
||||||
|
return; // Form validation failed — Antd shows inline errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedAliases = values.aliases
|
||||||
|
? values.aliases
|
||||||
|
.split(",")
|
||||||
|
.map((a: string) => a.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit && entity) {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
name: values.name,
|
||||||
|
description: values.description || undefined,
|
||||||
|
aliases: parsedAliases.length > 0 ? parsedAliases : undefined,
|
||||||
|
properties: entity.properties,
|
||||||
|
confidence: values.confidence,
|
||||||
|
});
|
||||||
|
await api.submitReview(graphId, {
|
||||||
|
operationType: "UPDATE_ENTITY",
|
||||||
|
entityId: entity.id,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
message.success("实体更新已提交审核");
|
||||||
|
} else {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
name: values.name,
|
||||||
|
type: values.type,
|
||||||
|
description: values.description || undefined,
|
||||||
|
aliases: parsedAliases.length > 0 ? parsedAliases : undefined,
|
||||||
|
properties: {},
|
||||||
|
confidence: values.confidence,
|
||||||
|
});
|
||||||
|
await api.submitReview(graphId, {
|
||||||
|
operationType: "CREATE_ENTITY",
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
message.success("实体创建已提交审核");
|
||||||
|
}
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
message.error(isEdit ? "提交实体更新审核失败" : "提交实体创建审核失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={isEdit ? "编辑实体" : "创建实体"}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={handleSubmit}
|
||||||
|
okText={isEdit ? "提交审核" : "提交审核"}
|
||||||
|
cancelText="取消"
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" className="mt-4">
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="名称"
|
||||||
|
rules={[{ required: true, message: "请输入实体名称" }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="输入实体名称" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="type"
|
||||||
|
label="类型"
|
||||||
|
rules={[{ required: true, message: "请选择实体类型" }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="选择实体类型"
|
||||||
|
disabled={isEdit}
|
||||||
|
options={ENTITY_TYPES.map((t) => ({
|
||||||
|
label: ENTITY_TYPE_LABELS[t] ?? t,
|
||||||
|
value: t,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<Input.TextArea rows={3} placeholder="输入实体描述(可选)" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="aliases"
|
||||||
|
label="别名"
|
||||||
|
tooltip="多个别名用逗号分隔"
|
||||||
|
>
|
||||||
|
<Input placeholder="别名1, 别名2, ..." />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="confidence" label="置信度">
|
||||||
|
<InputNumber min={0} max={1} step={0.1} className="w-full" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,10 +10,12 @@ interface GraphCanvasProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
layoutType: LayoutType;
|
layoutType: LayoutType;
|
||||||
highlightedNodeIds?: Set<string>;
|
highlightedNodeIds?: Set<string>;
|
||||||
|
editMode?: boolean;
|
||||||
onNodeClick?: (nodeId: string) => void;
|
onNodeClick?: (nodeId: string) => void;
|
||||||
onEdgeClick?: (edgeId: string) => void;
|
onEdgeClick?: (edgeId: string) => void;
|
||||||
onNodeDoubleClick?: (nodeId: string) => void;
|
onNodeDoubleClick?: (nodeId: string) => void;
|
||||||
onCanvasClick?: () => void;
|
onCanvasClick?: () => void;
|
||||||
|
onSelectionChange?: (nodeIds: string[], edgeIds: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function GraphCanvas({
|
function GraphCanvas({
|
||||||
@@ -21,10 +23,12 @@ function GraphCanvas({
|
|||||||
loading = false,
|
loading = false,
|
||||||
layoutType,
|
layoutType,
|
||||||
highlightedNodeIds,
|
highlightedNodeIds,
|
||||||
|
editMode = false,
|
||||||
onNodeClick,
|
onNodeClick,
|
||||||
onEdgeClick,
|
onEdgeClick,
|
||||||
onNodeDoubleClick,
|
onNodeDoubleClick,
|
||||||
onCanvasClick,
|
onCanvasClick,
|
||||||
|
onSelectionChange,
|
||||||
}: GraphCanvasProps) {
|
}: GraphCanvasProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const graphRef = useRef<Graph | null>(null);
|
const graphRef = useRef<Graph | null>(null);
|
||||||
@@ -33,7 +37,7 @@ function GraphCanvas({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
const options = createGraphOptions(containerRef.current);
|
const options = createGraphOptions(containerRef.current, editMode);
|
||||||
const graph = new Graph(options);
|
const graph = new Graph(options);
|
||||||
graphRef.current = graph;
|
graphRef.current = graph;
|
||||||
|
|
||||||
@@ -43,7 +47,8 @@ function GraphCanvas({
|
|||||||
graphRef.current = null;
|
graphRef.current = null;
|
||||||
graph.destroy();
|
graph.destroy();
|
||||||
};
|
};
|
||||||
}, []);
|
// editMode is intentionally included so the graph re-creates with correct multi-select setting
|
||||||
|
}, [editMode]);
|
||||||
|
|
||||||
// Update data (with large-graph performance optimization)
|
// Update data (with large-graph performance optimization)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -120,6 +125,25 @@ function GraphCanvas({
|
|||||||
});
|
});
|
||||||
}, [highlightedNodeIds, data]);
|
}, [highlightedNodeIds, data]);
|
||||||
|
|
||||||
|
// Helper: query selected elements from graph and notify parent
|
||||||
|
const emitSelectionChange = useCallback(() => {
|
||||||
|
const graph = graphRef.current;
|
||||||
|
if (!graph || !onSelectionChange) return;
|
||||||
|
// Defer to next tick so G6 internal state has settled
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const selectedNodes = graph.getElementDataByState("node", "selected");
|
||||||
|
const selectedEdges = graph.getElementDataByState("edge", "selected");
|
||||||
|
onSelectionChange(
|
||||||
|
selectedNodes.map((n: { id: string }) => n.id),
|
||||||
|
selectedEdges.map((e: { id: string }) => e.id)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// graph may be destroyed
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}, [onSelectionChange]);
|
||||||
|
|
||||||
// Bind events
|
// Bind events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const graph = graphRef.current;
|
const graph = graphRef.current;
|
||||||
@@ -127,15 +151,18 @@ function GraphCanvas({
|
|||||||
|
|
||||||
const handleNodeClick = (event: { target: { id: string } }) => {
|
const handleNodeClick = (event: { target: { id: string } }) => {
|
||||||
onNodeClick?.(event.target.id);
|
onNodeClick?.(event.target.id);
|
||||||
|
emitSelectionChange();
|
||||||
};
|
};
|
||||||
const handleEdgeClick = (event: { target: { id: string } }) => {
|
const handleEdgeClick = (event: { target: { id: string } }) => {
|
||||||
onEdgeClick?.(event.target.id);
|
onEdgeClick?.(event.target.id);
|
||||||
|
emitSelectionChange();
|
||||||
};
|
};
|
||||||
const handleNodeDblClick = (event: { target: { id: string } }) => {
|
const handleNodeDblClick = (event: { target: { id: string } }) => {
|
||||||
onNodeDoubleClick?.(event.target.id);
|
onNodeDoubleClick?.(event.target.id);
|
||||||
};
|
};
|
||||||
const handleCanvasClick = () => {
|
const handleCanvasClick = () => {
|
||||||
onCanvasClick?.();
|
onCanvasClick?.();
|
||||||
|
emitSelectionChange();
|
||||||
};
|
};
|
||||||
|
|
||||||
graph.on("node:click", handleNodeClick);
|
graph.on("node:click", handleNodeClick);
|
||||||
@@ -149,7 +176,7 @@ function GraphCanvas({
|
|||||||
graph.off("node:dblclick", handleNodeDblClick);
|
graph.off("node:dblclick", handleNodeDblClick);
|
||||||
graph.off("canvas:click", handleCanvasClick);
|
graph.off("canvas:click", handleCanvasClick);
|
||||||
};
|
};
|
||||||
}, [onNodeClick, onEdgeClick, onNodeDoubleClick, onCanvasClick]);
|
}, [onNodeClick, onEdgeClick, onNodeDoubleClick, onCanvasClick, emitSelectionChange]);
|
||||||
|
|
||||||
// Fit view helper
|
// Fit view helper
|
||||||
const handleFitView = useCallback(() => {
|
const handleFitView = useCallback(() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Drawer, Descriptions, Tag, List, Button, Spin, Empty, message } from "antd";
|
import { Drawer, Descriptions, Tag, List, Button, Spin, Empty, Popconfirm, Space, message } from "antd";
|
||||||
import { Expand } from "lucide-react";
|
import { Expand, Pencil, Trash2 } from "lucide-react";
|
||||||
import type { GraphEntity, RelationVO, PagedResponse } from "../knowledge-graph.model";
|
import type { GraphEntity, RelationVO, PagedResponse } from "../knowledge-graph.model";
|
||||||
import {
|
import {
|
||||||
ENTITY_TYPE_LABELS,
|
ENTITY_TYPE_LABELS,
|
||||||
@@ -14,20 +14,28 @@ interface NodeDetailProps {
|
|||||||
graphId: string;
|
graphId: string;
|
||||||
entityId: string | null;
|
entityId: string | null;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
editMode?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onExpandNode: (entityId: string) => void;
|
onExpandNode: (entityId: string) => void;
|
||||||
onRelationClick: (relationId: string) => void;
|
onRelationClick: (relationId: string) => void;
|
||||||
onEntityNavigate: (entityId: string) => void;
|
onEntityNavigate: (entityId: string) => void;
|
||||||
|
onEditEntity?: (entity: GraphEntity) => void;
|
||||||
|
onDeleteEntity?: (entityId: string) => void;
|
||||||
|
onCreateRelation?: (sourceEntityId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NodeDetail({
|
export default function NodeDetail({
|
||||||
graphId,
|
graphId,
|
||||||
entityId,
|
entityId,
|
||||||
open,
|
open,
|
||||||
|
editMode = false,
|
||||||
onClose,
|
onClose,
|
||||||
onExpandNode,
|
onExpandNode,
|
||||||
onRelationClick,
|
onRelationClick,
|
||||||
onEntityNavigate,
|
onEntityNavigate,
|
||||||
|
onEditEntity,
|
||||||
|
onDeleteEntity,
|
||||||
|
onCreateRelation,
|
||||||
}: NodeDetailProps) {
|
}: NodeDetailProps) {
|
||||||
const [entity, setEntity] = useState<GraphEntity | null>(null);
|
const [entity, setEntity] = useState<GraphEntity | null>(null);
|
||||||
const [relations, setRelations] = useState<RelationVO[]>([]);
|
const [relations, setRelations] = useState<RelationVO[]>([]);
|
||||||
@@ -58,6 +66,12 @@ export default function NodeDetail({
|
|||||||
});
|
});
|
||||||
}, [graphId, entityId, open]);
|
}, [graphId, entityId, open]);
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (entityId) {
|
||||||
|
onDeleteEntity?.(entityId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
title={
|
title={
|
||||||
@@ -75,14 +89,42 @@ export default function NodeDetail({
|
|||||||
width={420}
|
width={420}
|
||||||
extra={
|
extra={
|
||||||
entityId && (
|
entityId && (
|
||||||
<Button
|
<Space>
|
||||||
type="primary"
|
{editMode && entity && (
|
||||||
size="small"
|
<>
|
||||||
icon={<Expand className="w-3 h-3" />}
|
<Button
|
||||||
onClick={() => onExpandNode(entityId)}
|
size="small"
|
||||||
>
|
icon={<Pencil className="w-3 h-3" />}
|
||||||
展开邻居
|
onClick={() => onEditEntity?.(entity)}
|
||||||
</Button>
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除此实体?"
|
||||||
|
description="删除后关联的关系也会被移除"
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
okText="确认"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<Trash2 className="w-3 h-3" />}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<Expand className="w-3 h-3" />}
|
||||||
|
onClick={() => onExpandNode(entityId)}
|
||||||
|
>
|
||||||
|
展开邻居
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -130,7 +172,18 @@ export default function NodeDetail({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h4 className="font-medium text-sm">关系列表 ({relations.length})</h4>
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="font-medium text-sm">关系列表 ({relations.length})</h4>
|
||||||
|
{editMode && entityId && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
onClick={() => onCreateRelation?.(entityId)}
|
||||||
|
>
|
||||||
|
+ 添加关系
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{relations.length > 0 ? (
|
{relations.length > 0 ? (
|
||||||
<List
|
<List
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Drawer, Descriptions, Tag, Spin, Empty, message } from "antd";
|
import { Drawer, Descriptions, Tag, Spin, Empty, Button, Popconfirm, Space, message } from "antd";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import type { RelationVO } from "../knowledge-graph.model";
|
import type { RelationVO } from "../knowledge-graph.model";
|
||||||
import {
|
import {
|
||||||
ENTITY_TYPE_LABELS,
|
ENTITY_TYPE_LABELS,
|
||||||
@@ -13,16 +14,22 @@ interface RelationDetailProps {
|
|||||||
graphId: string;
|
graphId: string;
|
||||||
relationId: string | null;
|
relationId: string | null;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
editMode?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onEntityNavigate: (entityId: string) => void;
|
onEntityNavigate: (entityId: string) => void;
|
||||||
|
onEditRelation?: (relation: RelationVO) => void;
|
||||||
|
onDeleteRelation?: (relationId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RelationDetail({
|
export default function RelationDetail({
|
||||||
graphId,
|
graphId,
|
||||||
relationId,
|
relationId,
|
||||||
open,
|
open,
|
||||||
|
editMode = false,
|
||||||
onClose,
|
onClose,
|
||||||
onEntityNavigate,
|
onEntityNavigate,
|
||||||
|
onEditRelation,
|
||||||
|
onDeleteRelation,
|
||||||
}: RelationDetailProps) {
|
}: RelationDetailProps) {
|
||||||
const [relation, setRelation] = useState<RelationVO | null>(null);
|
const [relation, setRelation] = useState<RelationVO | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -42,8 +49,46 @@ export default function RelationDetail({
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [graphId, relationId, open]);
|
}, [graphId, relationId, open]);
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (relationId) {
|
||||||
|
onDeleteRelation?.(relationId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer title="关系详情" open={open} onClose={onClose} width={400}>
|
<Drawer
|
||||||
|
title="关系详情"
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
width={400}
|
||||||
|
extra={
|
||||||
|
editMode && relation && (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<Pencil className="w-3 h-3" />}
|
||||||
|
onClick={() => onEditRelation?.(relation)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除此关系?"
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
okText="确认"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<Trash2 className="w-3 h-3" />}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
<Spin spinning={loading}>
|
<Spin spinning={loading}>
|
||||||
{relation ? (
|
{relation ? (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { Modal, Form, Select, InputNumber, message, Spin } from "antd";
|
||||||
|
import type { RelationVO, GraphEntity } from "../knowledge-graph.model";
|
||||||
|
import { RELATION_TYPES, RELATION_TYPE_LABELS } from "../knowledge-graph.const";
|
||||||
|
import * as api from "../knowledge-graph.api";
|
||||||
|
|
||||||
|
interface RelationEditFormProps {
|
||||||
|
graphId: string;
|
||||||
|
relation?: RelationVO | null;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
/** Pre-fill source entity when creating from a node context */
|
||||||
|
defaultSourceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RelationEditForm({
|
||||||
|
graphId,
|
||||||
|
relation,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
defaultSourceId,
|
||||||
|
}: RelationEditFormProps) {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const isEdit = !!relation;
|
||||||
|
const [entityOptions, setEntityOptions] = useState<
|
||||||
|
{ label: string; value: string }[]
|
||||||
|
>([]);
|
||||||
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && relation) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
relationType: relation.relationType,
|
||||||
|
sourceEntityId: relation.sourceEntityId,
|
||||||
|
targetEntityId: relation.targetEntityId,
|
||||||
|
weight: relation.weight,
|
||||||
|
confidence: relation.confidence,
|
||||||
|
});
|
||||||
|
} else if (open) {
|
||||||
|
form.resetFields();
|
||||||
|
if (defaultSourceId) {
|
||||||
|
form.setFieldsValue({ sourceEntityId: defaultSourceId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, relation, form, defaultSourceId]);
|
||||||
|
|
||||||
|
const searchEntities = useCallback(
|
||||||
|
async (keyword: string) => {
|
||||||
|
if (!keyword.trim() || !graphId) return;
|
||||||
|
setSearchLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await api.listEntitiesPaged(graphId, {
|
||||||
|
keyword,
|
||||||
|
page: 0,
|
||||||
|
size: 20,
|
||||||
|
});
|
||||||
|
setEntityOptions(
|
||||||
|
result.content.map((e: GraphEntity) => ({
|
||||||
|
label: `${e.name} (${e.type})`,
|
||||||
|
value: e.id,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setSearchLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[graphId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
let values;
|
||||||
|
try {
|
||||||
|
values = await form.validateFields();
|
||||||
|
} catch {
|
||||||
|
return; // Form validation failed — Antd shows inline errors
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit && relation) {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
relationType: values.relationType,
|
||||||
|
weight: values.weight,
|
||||||
|
confidence: values.confidence,
|
||||||
|
});
|
||||||
|
await api.submitReview(graphId, {
|
||||||
|
operationType: "UPDATE_RELATION",
|
||||||
|
relationId: relation.id,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
message.success("关系更新已提交审核");
|
||||||
|
} else {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
sourceEntityId: values.sourceEntityId,
|
||||||
|
targetEntityId: values.targetEntityId,
|
||||||
|
relationType: values.relationType,
|
||||||
|
weight: values.weight,
|
||||||
|
confidence: values.confidence,
|
||||||
|
});
|
||||||
|
await api.submitReview(graphId, {
|
||||||
|
operationType: "CREATE_RELATION",
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
message.success("关系创建已提交审核");
|
||||||
|
}
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
message.error(isEdit ? "提交关系更新审核失败" : "提交关系创建审核失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={isEdit ? "编辑关系" : "创建关系"}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={handleSubmit}
|
||||||
|
okText="提交审核"
|
||||||
|
cancelText="取消"
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" className="mt-4">
|
||||||
|
<Form.Item
|
||||||
|
name="sourceEntityId"
|
||||||
|
label="源实体"
|
||||||
|
rules={[{ required: true, message: "请选择源实体" }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
placeholder="搜索并选择源实体"
|
||||||
|
disabled={isEdit}
|
||||||
|
filterOption={false}
|
||||||
|
onSearch={searchEntities}
|
||||||
|
options={entityOptions}
|
||||||
|
notFoundContent={searchLoading ? <Spin size="small" /> : null}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="targetEntityId"
|
||||||
|
label="目标实体"
|
||||||
|
rules={[{ required: true, message: "请选择目标实体" }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
placeholder="搜索并选择目标实体"
|
||||||
|
disabled={isEdit}
|
||||||
|
filterOption={false}
|
||||||
|
onSearch={searchEntities}
|
||||||
|
options={entityOptions}
|
||||||
|
notFoundContent={searchLoading ? <Spin size="small" /> : null}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="relationType"
|
||||||
|
label="关系类型"
|
||||||
|
rules={[{ required: true, message: "请选择关系类型" }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="选择关系类型"
|
||||||
|
options={RELATION_TYPES.map((t) => ({
|
||||||
|
label: RELATION_TYPE_LABELS[t] ?? t,
|
||||||
|
value: t,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="weight" label="权重">
|
||||||
|
<InputNumber min={0} max={1} step={0.1} className="w-full" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="confidence" label="置信度">
|
||||||
|
<InputNumber min={0} max={1} step={0.1} className="w-full" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
frontend/src/pages/KnowledgeGraph/components/ReviewPanel.tsx
Normal file
206
frontend/src/pages/KnowledgeGraph/components/ReviewPanel.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { List, Tag, Button, Empty, Spin, Popconfirm, Input, message } from "antd";
|
||||||
|
import { Check, X } from "lucide-react";
|
||||||
|
import type { EditReviewVO, PagedResponse } from "../knowledge-graph.model";
|
||||||
|
import * as api from "../knowledge-graph.api";
|
||||||
|
|
||||||
|
const OPERATION_LABELS: Record<string, string> = {
|
||||||
|
CREATE_ENTITY: "创建实体",
|
||||||
|
UPDATE_ENTITY: "更新实体",
|
||||||
|
DELETE_ENTITY: "删除实体",
|
||||||
|
CREATE_RELATION: "创建关系",
|
||||||
|
UPDATE_RELATION: "更新关系",
|
||||||
|
DELETE_RELATION: "删除关系",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
PENDING: "orange",
|
||||||
|
APPROVED: "green",
|
||||||
|
REJECTED: "red",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
PENDING: "待审核",
|
||||||
|
APPROVED: "已通过",
|
||||||
|
REJECTED: "已拒绝",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ReviewPanelProps {
|
||||||
|
graphId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReviewPanel({ graphId }: ReviewPanelProps) {
|
||||||
|
const [reviews, setReviews] = useState<EditReviewVO[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
|
const loadReviews = useCallback(async () => {
|
||||||
|
if (!graphId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result: PagedResponse<EditReviewVO> = await api.listPendingReviews(
|
||||||
|
graphId,
|
||||||
|
{ page: 0, size: 50 }
|
||||||
|
);
|
||||||
|
setReviews(result.content);
|
||||||
|
setTotal(result.totalElements);
|
||||||
|
} catch {
|
||||||
|
message.error("加载审核列表失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [graphId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadReviews();
|
||||||
|
}, [loadReviews]);
|
||||||
|
|
||||||
|
const handleApprove = useCallback(
|
||||||
|
async (reviewId: string) => {
|
||||||
|
try {
|
||||||
|
await api.approveReview(graphId, reviewId);
|
||||||
|
message.success("审核通过");
|
||||||
|
loadReviews();
|
||||||
|
} catch {
|
||||||
|
message.error("审核操作失败");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[graphId, loadReviews]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReject = useCallback(
|
||||||
|
async (reviewId: string, comment: string) => {
|
||||||
|
try {
|
||||||
|
await api.rejectReview(graphId, reviewId, { comment });
|
||||||
|
message.success("已拒绝");
|
||||||
|
loadReviews();
|
||||||
|
} catch {
|
||||||
|
message.error("审核操作失败");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[graphId, loadReviews]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
待审核: {total}
|
||||||
|
</span>
|
||||||
|
<Button size="small" onClick={loadReviews}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
{reviews.length > 0 ? (
|
||||||
|
<List
|
||||||
|
size="small"
|
||||||
|
dataSource={reviews}
|
||||||
|
renderItem={(review) => (
|
||||||
|
<ReviewItem
|
||||||
|
review={review}
|
||||||
|
onApprove={handleApprove}
|
||||||
|
onReject={handleReject}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
description="暂无待审核项"
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewItem({
|
||||||
|
review,
|
||||||
|
onApprove,
|
||||||
|
onReject,
|
||||||
|
}: {
|
||||||
|
review: EditReviewVO;
|
||||||
|
onApprove: (id: string) => void;
|
||||||
|
onReject: (id: string, comment: string) => void;
|
||||||
|
}) {
|
||||||
|
const [rejectComment, setRejectComment] = useState("");
|
||||||
|
|
||||||
|
const payload = review.payload ? tryParsePayload(review.payload) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List.Item className="!px-2">
|
||||||
|
<div className="flex flex-col gap-1.5 w-full">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Tag color={STATUS_COLORS[review.status] ?? "default"}>
|
||||||
|
{STATUS_LABELS[review.status] ?? review.status}
|
||||||
|
</Tag>
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
{OPERATION_LABELS[review.operationType] ?? review.operationType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{payload && (
|
||||||
|
<div className="text-xs text-gray-500 truncate">
|
||||||
|
{payload.name && <span>名称: {payload.name} </span>}
|
||||||
|
{payload.relationType && <span>类型: {payload.relationType}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{review.submittedBy && <span>提交人: {review.submittedBy}</span>}
|
||||||
|
{review.createdAt && <span className="ml-2">{review.createdAt}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{review.status === "PENDING" && (
|
||||||
|
<div className="flex gap-1.5 mt-1">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<Check className="w-3 h-3" />}
|
||||||
|
onClick={() => onApprove(review.id)}
|
||||||
|
>
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="拒绝审核"
|
||||||
|
description={
|
||||||
|
<Input.TextArea
|
||||||
|
rows={2}
|
||||||
|
placeholder="拒绝原因(可选)"
|
||||||
|
value={rejectComment}
|
||||||
|
onChange={(e) => setRejectComment(e.target.value)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onConfirm={() => {
|
||||||
|
onReject(review.id, rejectComment);
|
||||||
|
setRejectComment("");
|
||||||
|
}}
|
||||||
|
okText="确认拒绝"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<X className="w-3 h-3" />}
|
||||||
|
>
|
||||||
|
拒绝
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParsePayload(
|
||||||
|
payload: string
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(payload);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { ENTITY_TYPE_COLORS, DEFAULT_ENTITY_COLOR } from "./knowledge-graph.cons
|
|||||||
export const LARGE_GRAPH_THRESHOLD = 200;
|
export const LARGE_GRAPH_THRESHOLD = 200;
|
||||||
|
|
||||||
/** Create the G6 v5 graph options. */
|
/** Create the G6 v5 graph options. */
|
||||||
export function createGraphOptions(container: HTMLElement) {
|
export function createGraphOptions(container: HTMLElement, multiSelect = false) {
|
||||||
return {
|
return {
|
||||||
container,
|
container,
|
||||||
autoFit: "view" as const,
|
autoFit: "view" as const,
|
||||||
@@ -99,7 +99,7 @@ export function createGraphOptions(container: HTMLElement) {
|
|||||||
"drag-element",
|
"drag-element",
|
||||||
{
|
{
|
||||||
type: "click-select" as const,
|
type: "click-select" as const,
|
||||||
multiple: false,
|
multiple: multiSelect,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import type {
|
|||||||
PagedResponse,
|
PagedResponse,
|
||||||
PathVO,
|
PathVO,
|
||||||
AllPathsVO,
|
AllPathsVO,
|
||||||
|
EditReviewVO,
|
||||||
} from "./knowledge-graph.model";
|
} from "./knowledge-graph.model";
|
||||||
|
|
||||||
const BASE = "/knowledge-graph";
|
const BASE = "/api/knowledge-graph";
|
||||||
|
|
||||||
// ---- Entity ----
|
// ---- Entity ----
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ export function listEntitiesPaged(
|
|||||||
|
|
||||||
export function createEntity(
|
export function createEntity(
|
||||||
graphId: string,
|
graphId: string,
|
||||||
data: { name: string; type: string; description?: string; properties?: Record<string, unknown> }
|
data: { name: string; type: string; description?: string; aliases?: string[]; properties?: Record<string, unknown>; confidence?: number }
|
||||||
): Promise<GraphEntity> {
|
): Promise<GraphEntity> {
|
||||||
return post(`${BASE}/${graphId}/entities`, data);
|
return post(`${BASE}/${graphId}/entities`, data);
|
||||||
}
|
}
|
||||||
@@ -41,7 +42,7 @@ export function createEntity(
|
|||||||
export function updateEntity(
|
export function updateEntity(
|
||||||
graphId: string,
|
graphId: string,
|
||||||
entityId: string,
|
entityId: string,
|
||||||
data: { name?: string; type?: string; description?: string; properties?: Record<string, unknown> }
|
data: { name?: string; description?: string; aliases?: string[]; properties?: Record<string, unknown>; confidence?: number }
|
||||||
): Promise<GraphEntity> {
|
): Promise<GraphEntity> {
|
||||||
return put(`${BASE}/${graphId}/entities/${entityId}`, data);
|
return put(`${BASE}/${graphId}/entities/${entityId}`, data);
|
||||||
}
|
}
|
||||||
@@ -146,3 +147,47 @@ export function getEntityNeighbors(
|
|||||||
): Promise<GraphEntity[]> {
|
): Promise<GraphEntity[]> {
|
||||||
return get(`${BASE}/${graphId}/entities/${entityId}/neighbors`, params ?? null);
|
return get(`${BASE}/${graphId}/entities/${entityId}/neighbors`, params ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Review ----
|
||||||
|
|
||||||
|
export function submitReview(
|
||||||
|
graphId: string,
|
||||||
|
data: {
|
||||||
|
operationType: string;
|
||||||
|
entityId?: string;
|
||||||
|
relationId?: string;
|
||||||
|
payload?: string;
|
||||||
|
}
|
||||||
|
): Promise<EditReviewVO> {
|
||||||
|
return post(`${BASE}/${graphId}/review/submit`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function approveReview(
|
||||||
|
graphId: string,
|
||||||
|
reviewId: string,
|
||||||
|
data?: { comment?: string }
|
||||||
|
): Promise<EditReviewVO> {
|
||||||
|
return post(`${BASE}/${graphId}/review/${reviewId}/approve`, data ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectReview(
|
||||||
|
graphId: string,
|
||||||
|
reviewId: string,
|
||||||
|
data?: { comment?: string }
|
||||||
|
): Promise<EditReviewVO> {
|
||||||
|
return post(`${BASE}/${graphId}/review/${reviewId}/reject`, data ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPendingReviews(
|
||||||
|
graphId: string,
|
||||||
|
params?: { page?: number; size?: number }
|
||||||
|
): Promise<PagedResponse<EditReviewVO>> {
|
||||||
|
return get(`${BASE}/${graphId}/review/pending`, params ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listReviews(
|
||||||
|
graphId: string,
|
||||||
|
params?: { status?: string; page?: number; size?: number }
|
||||||
|
): Promise<PagedResponse<EditReviewVO>> {
|
||||||
|
return get(`${BASE}/${graphId}/review`, params ?? null);
|
||||||
|
}
|
||||||
|
|||||||
@@ -79,3 +79,30 @@ export interface AllPathsVO {
|
|||||||
paths: PathVO[];
|
paths: PathVO[];
|
||||||
pathCount: number;
|
pathCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Edit Review ----
|
||||||
|
|
||||||
|
export type ReviewOperationType =
|
||||||
|
| "CREATE_ENTITY"
|
||||||
|
| "UPDATE_ENTITY"
|
||||||
|
| "DELETE_ENTITY"
|
||||||
|
| "CREATE_RELATION"
|
||||||
|
| "UPDATE_RELATION"
|
||||||
|
| "DELETE_RELATION";
|
||||||
|
|
||||||
|
export type ReviewStatus = "PENDING" | "APPROVED" | "REJECTED";
|
||||||
|
|
||||||
|
export interface EditReviewVO {
|
||||||
|
id: string;
|
||||||
|
graphId: string;
|
||||||
|
operationType: ReviewOperationType;
|
||||||
|
entityId?: string;
|
||||||
|
relationId?: string;
|
||||||
|
payload?: string;
|
||||||
|
status: ReviewStatus;
|
||||||
|
submittedBy?: string;
|
||||||
|
reviewedBy?: string;
|
||||||
|
reviewComment?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
reviewedAt?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ export default defineConfig({
|
|||||||
// "Origin, X-Requested-With, Content-Type, Accept",
|
// "Origin, X-Requested-With, Content-Type, Accept",
|
||||||
// },
|
// },
|
||||||
proxy: {
|
proxy: {
|
||||||
"^/knowledge-graph": {
|
"/api/knowledge-graph": {
|
||||||
target: "http://localhost:8080",
|
target: "http://localhost:8080",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||||
},
|
},
|
||||||
"^/api": {
|
"^/api": {
|
||||||
target: "http://localhost:8080", // 本地后端服务地址
|
target: "http://localhost:8080", // 本地后端服务地址
|
||||||
|
|||||||
Reference in New Issue
Block a user