Files
DataMate/frontend/src/pages/KnowledgeGraph/components/RelationDetail.tsx
Jerry Yan 3a9afe3480 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 
2026-02-20 20:38:03 +08:00

168 lines
5.4 KiB
TypeScript

import { useEffect, useState } from "react";
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 {
ENTITY_TYPE_LABELS,
ENTITY_TYPE_COLORS,
DEFAULT_ENTITY_COLOR,
RELATION_TYPE_LABELS,
} from "../knowledge-graph.const";
import * as api from "../knowledge-graph.api";
interface RelationDetailProps {
graphId: string;
relationId: string | null;
open: boolean;
editMode?: boolean;
onClose: () => void;
onEntityNavigate: (entityId: string) => void;
onEditRelation?: (relation: RelationVO) => void;
onDeleteRelation?: (relationId: string) => void;
}
export default function RelationDetail({
graphId,
relationId,
open,
editMode = false,
onClose,
onEntityNavigate,
onEditRelation,
onDeleteRelation,
}: RelationDetailProps) {
const [relation, setRelation] = useState<RelationVO | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!relationId || !graphId) {
setRelation(null);
return;
}
if (!open) return;
setLoading(true);
api
.getRelation(graphId, relationId)
.then((data) => setRelation(data))
.catch(() => message.error("加载关系详情失败"))
.finally(() => setLoading(false));
}, [graphId, relationId, open]);
const handleDelete = () => {
if (relationId) {
onDeleteRelation?.(relationId);
}
};
return (
<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}>
{relation ? (
<div className="flex flex-col gap-4">
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="关系类型">
<Tag color="blue">
{RELATION_TYPE_LABELS[relation.relationType] ?? relation.relationType}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="源实体">
<div className="flex items-center gap-1.5">
<Tag
color={
ENTITY_TYPE_COLORS[relation.sourceEntityType] ?? DEFAULT_ENTITY_COLOR
}
>
{ENTITY_TYPE_LABELS[relation.sourceEntityType] ?? relation.sourceEntityType}
</Tag>
<a
className="text-blue-500 cursor-pointer hover:underline"
onClick={() => onEntityNavigate(relation.sourceEntityId)}
>
{relation.sourceEntityName}
</a>
</div>
</Descriptions.Item>
<Descriptions.Item label="目标实体">
<div className="flex items-center gap-1.5">
<Tag
color={
ENTITY_TYPE_COLORS[relation.targetEntityType] ?? DEFAULT_ENTITY_COLOR
}
>
{ENTITY_TYPE_LABELS[relation.targetEntityType] ?? relation.targetEntityType}
</Tag>
<a
className="text-blue-500 cursor-pointer hover:underline"
onClick={() => onEntityNavigate(relation.targetEntityId)}
>
{relation.targetEntityName}
</a>
</div>
</Descriptions.Item>
{relation.weight != null && (
<Descriptions.Item label="权重">{relation.weight}</Descriptions.Item>
)}
{relation.confidence != null && (
<Descriptions.Item label="置信度">
{(relation.confidence * 100).toFixed(0)}%
</Descriptions.Item>
)}
{relation.createdAt && (
<Descriptions.Item label="创建时间">{relation.createdAt}</Descriptions.Item>
)}
</Descriptions>
{relation.properties && Object.keys(relation.properties).length > 0 && (
<>
<h4 className="font-medium text-sm"></h4>
<Descriptions column={1} size="small" bordered>
{Object.entries(relation.properties).map(([key, value]) => (
<Descriptions.Item key={key} label={key}>
{String(value)}
</Descriptions.Item>
))}
</Descriptions>
</>
)}
</div>
) : !loading ? (
<Empty description="选择一条边查看详情" />
) : null}
</Spin>
</Drawer>
);
}